From 8644b75cc55ed9de19822332813645e06c7ecd28 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 13:48:42 +0000 Subject: [PATCH 01/25] moved ActiveTimersOverlay into SelectionProvider --- packages/frontend/src/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 9e9b102..8a7761d 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -55,9 +55,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> + - From 1aabc463d46b7754b3f8622d74c8846abe5c4cba Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 17:21:11 +0000 Subject: [PATCH 02/25] added function to etch timed sessions for ALL org members --- .../backend/src/db/queries/timed-sessions.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/db/queries/timed-sessions.ts b/packages/backend/src/db/queries/timed-sessions.ts index d0a0ee4..b4fc924 100644 --- a/packages/backend/src/db/queries/timed-sessions.ts +++ b/packages/backend/src/db/queries/timed-sessions.ts @@ -1,6 +1,45 @@ -import { Issue, Project, TimedSession } from "@sprint/shared"; -import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; -import { db } from "../client"; +import { Issue, OrganisationMember, Project, TimedSession } from "@sprint/shared"; +import { and, desc, eq, gte, inArray, isNotNull, isNull } from "drizzle-orm"; +import { db } from "../client"; // Import OrganisationMember and gte, inArray for the new query + +export async function getOrganisationMemberTimedSessions(organisationId: number, fromDate?: Date) { + // First get all member user IDs for the organisation + const members = await db + .select({ userId: OrganisationMember.userId }) + .from(OrganisationMember) + .where(eq(OrganisationMember.organisationId, organisationId)); + + const userIds = members.map((m) => m.userId); + + if (userIds.length === 0) { + return []; + } + + // Build the where clause + const conditions = [inArray(TimedSession.userId, userIds)]; + if (fromDate) { + conditions.push(gte(TimedSession.createdAt, fromDate)); + } + + const timedSessions = await db + .select({ + id: TimedSession.id, + userId: TimedSession.userId, + issueId: TimedSession.issueId, + timestamps: TimedSession.timestamps, + endedAt: TimedSession.endedAt, + createdAt: TimedSession.createdAt, + issueNumber: Issue.number, + projectKey: Project.key, + }) + .from(TimedSession) + .innerJoin(Issue, eq(TimedSession.issueId, Issue.id)) + .innerJoin(Project, eq(Issue.projectId, Project.id)) + .where(and(...conditions)) + .orderBy(desc(TimedSession.createdAt)); + + return timedSessions; +} export async function createTimedSession(userId: number, issueId: number) { const [timedSession] = await db From c2f5a9abdb0228263a9a7c13448dcbaa6705342d Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 17:23:20 +0000 Subject: [PATCH 03/25] added member-time-tracking route --- packages/backend/src/index.ts | 13 ++++ packages/backend/src/routes/index.ts | 11 ++++ .../organisation/member-time-tracking.ts | 64 +++++++++++++++++++ packages/shared/src/api-schemas.ts | 7 ++ packages/shared/src/contract.ts | 25 ++++++++ packages/shared/src/index.ts | 12 ++++ 6 files changed, 132 insertions(+) create mode 100644 packages/backend/src/routes/organisation/member-time-tracking.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 843ffec..82877cc 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -68,6 +68,9 @@ const main = async () => { "/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))), "/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))), "/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)), + "/organisation/member-time-tracking": withGlobalAuthed( + withAuth(routes.organisationMemberTimeTracking), + ), "/organisation/remove-member": withGlobalAuthed( withAuth(withCSRF(routes.organisationRemoveMember)), ), @@ -97,6 +100,16 @@ const main = async () => { "/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))), "/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))), "/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))), + + // subscription routes - webhook has no auth + "/subscription/create-checkout-session": withGlobalAuthed( + withAuth(withCSRF(routes.subscriptionCreateCheckoutSession)), + ), + "/subscription/create-portal-session": withGlobalAuthed( + withAuth(withCSRF(routes.subscriptionCreatePortalSession)), + ), + "/subscription/get": withGlobalAuthed(withAuth(routes.subscriptionGet)), + "/subscription/webhook": withGlobal(routes.subscriptionWebhook), }, }); diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 2176fdb..d52e2b9 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -20,6 +20,7 @@ import organisationById from "./organisation/by-id"; import organisationsByUser from "./organisation/by-user"; import organisationCreate from "./organisation/create"; import organisationDelete from "./organisation/delete"; +import organisationMemberTimeTracking from "./organisation/member-time-tracking"; import organisationMembers from "./organisation/members"; import organisationRemoveMember from "./organisation/remove-member"; import organisationUpdate from "./organisation/update"; @@ -37,6 +38,10 @@ import sprintCreate from "./sprint/create"; import sprintDelete from "./sprint/delete"; import sprintUpdate from "./sprint/update"; import sprintsByProject from "./sprints/by-project"; +import subscriptionCreateCheckoutSession from "./subscription/create-checkout-session"; +import subscriptionCreatePortalSession from "./subscription/create-portal-session"; +import subscriptionGet from "./subscription/get"; +import subscriptionWebhook from "./subscription/webhook"; import timerEnd from "./timer/end"; import timerGet from "./timer/get"; import timerGetInactive from "./timer/get-inactive"; @@ -77,6 +82,7 @@ export const routes = { organisationUpdate, organisationDelete, organisationAddMember, + organisationMemberTimeTracking, organisationMembers, organisationRemoveMember, organisationUpdateMemberRole, @@ -104,4 +110,9 @@ export const routes = { timerGetInactive, timerEnd, timers, + + subscriptionCreateCheckoutSession, + subscriptionCreatePortalSession, + subscriptionGet, + subscriptionWebhook, }; diff --git a/packages/backend/src/routes/organisation/member-time-tracking.ts b/packages/backend/src/routes/organisation/member-time-tracking.ts new file mode 100644 index 0000000..410376d --- /dev/null +++ b/packages/backend/src/routes/organisation/member-time-tracking.ts @@ -0,0 +1,64 @@ +import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared"; +import { z } from "zod"; +import type { AuthedRequest } from "../../auth/middleware"; +import { + getOrganisationById, + getOrganisationMemberRole, + getOrganisationMemberTimedSessions, +} from "../../db/queries"; +import { errorResponse, parseQueryParams } from "../../validation"; + +const OrgMemberTimeTrackingQuerySchema = z.object({ + organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), + fromDate: z.coerce.date().optional(), +}); + +// GET /organisation/member-time-tracking?organisationId=123&fromDate=2024-01-01 +export default async function organisationMemberTimeTracking(req: AuthedRequest) { + const url = new URL(req.url); + const parsed = parseQueryParams(url, OrgMemberTimeTrackingQuerySchema); + if ("error" in parsed) return parsed.error; + + const { organisationId, fromDate } = parsed.data; + + // Check organisation exists + const organisation = await getOrganisationById(organisationId); + if (!organisation) { + return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404); + } + + // Check user is admin or owner of the organisation + const memberRole = await getOrganisationMemberRole(organisationId, req.userId); + if (!memberRole) { + return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); + } + + const role = memberRole.role; + if (role !== "owner" && role !== "admin") { + return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403); + } + + // Get timed sessions for all organisation members + const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); + + // Enrich with calculated times + // timestamps come from the database as strings, need to convert to Date objects for calculation + const enriched = sessions.map((session) => { + const timestamps = session.timestamps.map((t) => new Date(t)); + return { + id: session.id, + userId: session.userId, + issueId: session.issueId, + issueNumber: session.issueNumber, + projectKey: session.projectKey, + timestamps: session.timestamps, // Return original strings for JSON serialization + endedAt: session.endedAt, + createdAt: session.createdAt, + workTimeMs: calculateWorkTimeMs(timestamps), + breakTimeMs: calculateBreakTimeMs(timestamps), + isRunning: session.endedAt === null && isTimerRunning(timestamps), + }; + }); + + return Response.json(enriched); +} diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 2e6c0dc..029a7cf 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -227,6 +227,13 @@ export const OrgMembersQuerySchema = z.object({ export type OrgMembersQuery = z.infer; +export const OrgMemberTimeTrackingQuerySchema = z.object({ + organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), + fromDate: z.coerce.date().optional(), +}); + +export type OrgMemberTimeTrackingQuery = z.infer; + export const OrgAddMemberRequestSchema = z.object({ organisationId: z.number().int().positive("organisationId must be a positive integer"), userId: z.number().int().positive("userId must be a positive integer"), diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index ea6b676..139f95e 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -29,6 +29,7 @@ import { OrgCreateRequestSchema, OrgDeleteRequestSchema, OrgMembersQuerySchema, + OrgMemberTimeTrackingQuerySchema, OrgRemoveMemberRequestSchema, OrgUpdateMemberRoleRequestSchema, OrgUpdateRequestSchema, @@ -379,6 +380,30 @@ export const apiContract = c.router({ 200: z.array(OrganisationMemberResponseSchema), }, }, + organisationMemberTimeTracking: { + method: "GET", + path: "/organisation/member-time-tracking", + query: OrgMemberTimeTrackingQuerySchema, + responses: { + 200: z.array( + z.object({ + id: z.number(), + userId: z.number(), + issueId: z.number(), + issueNumber: z.number(), + projectKey: z.string(), + timestamps: z.array(z.string()), + endedAt: z.string().nullable(), + createdAt: z.string().nullable(), + workTimeMs: z.number(), + breakTimeMs: z.number(), + isRunning: z.boolean(), + }), + ), + 403: ApiErrorSchema, + 404: ApiErrorSchema, + }, + }, organisationRemoveMember: { method: "POST", path: "/organisation/remove-member", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1b80089..587882e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,6 +25,7 @@ export type { OrgCreateRequest, OrgDeleteRequest, OrgMembersQuery, + OrgMemberTimeTrackingQuery, OrgRemoveMemberRequest, OrgUpdateMemberRoleRequest, OrgUpdateRequest, @@ -87,6 +88,7 @@ export { OrgCreateRequestSchema, OrgDeleteRequestSchema, OrgMembersQuerySchema, + OrgMemberTimeTrackingQuerySchema, OrgRemoveMemberRequestSchema, OrgUpdateMemberRoleRequestSchema, OrgUpdateRequestSchema, @@ -153,6 +155,8 @@ export type { OrganisationMemberResponse as OrganisationMemberResponseRecord, OrganisationRecord, OrganisationResponse as OrganisationResponseRecord, + PaymentInsert, + PaymentRecord, ProjectInsert, ProjectRecord, ProjectResponse as ProjectResponseRecord, @@ -160,6 +164,8 @@ export type { SessionRecord, SprintInsert, SprintRecord, + SubscriptionInsert, + SubscriptionRecord, TimedSessionInsert, TimedSessionRecord, TimerState, @@ -188,6 +194,9 @@ export { OrganisationMemberInsertSchema, OrganisationMemberSelectSchema, OrganisationSelectSchema, + Payment, + PaymentInsertSchema, + PaymentSelectSchema, Project, ProjectInsertSchema, ProjectSelectSchema, @@ -197,6 +206,9 @@ export { Sprint, SprintInsertSchema, SprintSelectSchema, + Subscription, + SubscriptionInsertSchema, + SubscriptionSelectSchema, TimedSession, TimedSessionInsertSchema, TimedSessionSelectSchema, From 56dcf1c24c4ecf7d70a03a0dd75ff0fdad91ada6 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 17:23:52 +0000 Subject: [PATCH 04/25] useOrganisationMemberTimeTracking() hook --- .../src/lib/query/hooks/organisations.ts | 31 +++++++++++++++++++ packages/frontend/src/lib/query/keys.ts | 2 ++ 2 files changed, 33 insertions(+) diff --git a/packages/frontend/src/lib/query/hooks/organisations.ts b/packages/frontend/src/lib/query/hooks/organisations.ts index 41188f0..527634c 100644 --- a/packages/frontend/src/lib/query/hooks/organisations.ts +++ b/packages/frontend/src/lib/query/hooks/organisations.ts @@ -14,6 +14,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query/keys"; import { apiClient } from "@/lib/server"; +export interface MemberTimeTrackingSession { + id: number; + userId: number; + issueId: number; + issueNumber: number; + projectKey: string; + timestamps: string[]; + endedAt: string | null; + createdAt: string | null; + workTimeMs: number; + breakTimeMs: number; + isRunning: boolean; +} + export function useOrganisations() { return useQuery({ queryKey: queryKeys.organisations.byUser(), @@ -39,6 +53,23 @@ export function useOrganisationMembers(organisationId?: number | null) { }); } +export function useOrganisationMemberTimeTracking(organisationId?: number | null, fromDate?: Date) { + return useQuery({ + queryKey: queryKeys.organisations.memberTimeTracking(organisationId ?? 0, fromDate?.toISOString()), + queryFn: async () => { + const { data, error } = await apiClient.organisationMemberTimeTracking({ + query: { + organisationId: organisationId ?? 0, + fromDate: fromDate, + }, + }); + if (error) throw new Error(error); + return (data ?? []) as MemberTimeTrackingSession[]; + }, + enabled: Boolean(organisationId), + }); +} + export function useCreateOrganisation() { const queryClient = useQueryClient(); diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index 67c3260..2c5903e 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -5,6 +5,8 @@ export const queryKeys = { all: ["organisations"] as const, byUser: () => [...queryKeys.organisations.all, "by-user"] as const, members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const, + memberTimeTracking: (orgId: number, fromDate?: string) => + [...queryKeys.organisations.all, orgId, "member-time-tracking", fromDate ?? "all"] as const, }, projects: { all: ["projects"] as const, From 0fffbfeb1f687f62f884a148d93ad3f049e0a584 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 17:31:01 +0000 Subject: [PATCH 05/25] organisation level time tracking data, export as JSON or CSV --- .../frontend/src/components/organisations.tsx | 187 +++++++++++++++++- packages/frontend/src/lib/utils.ts | 16 ++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index f0e6cd2..6eda4e7 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -22,6 +22,7 @@ import SmallUserDisplay from "@/components/small-user-display"; import { SprintForm } from "@/components/sprint-form"; import StatusTag from "@/components/status-tag"; import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; import ColourPicker from "@/components/ui/colour-picker"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; @@ -34,6 +35,7 @@ import { import Icon, { type IconName, iconNames } from "@/components/ui/icon"; import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -41,6 +43,7 @@ import { useDeleteProject, useDeleteSprint, useOrganisationMembers, + useOrganisationMemberTimeTracking, useOrganisations, useProjects, useRemoveOrganisationMember, @@ -52,7 +55,7 @@ import { } from "@/lib/query/hooks"; import { queryKeys } from "@/lib/query/keys"; import { apiClient } from "@/lib/server"; -import { capitalise, unCamelCase } from "@/lib/utils"; +import { capitalise, formatDuration, unCamelCase } from "@/lib/utils"; import { Switch } from "./ui/switch"; function Organisations({ trigger }: { trigger?: ReactNode }) { @@ -104,6 +107,15 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { ); const invalidateSprints = () => queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) }); + // time tracking state - must be before membersWithTimeTracking useMemo + const [fromDate, setFromDate] = useState(() => { + // default to same day of previous month + const now = new Date(); + const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); + return prevMonth; + }); + const { data: timeTrackingData = [] } = useOrganisationMemberTimeTracking(selectedOrganisationId, fromDate); + const members = useMemo(() => { const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; return [...membersData].sort((a, b) => { @@ -114,6 +126,126 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { }); }, [membersData]); + // Calculate total time per member and sort by time (greatest to smallest) + const membersWithTimeTracking = useMemo(() => { + // Calculate total time per user + const timePerUser = new Map(); + for (const session of timeTrackingData) { + const current = timePerUser.get(session.userId) ?? 0; + timePerUser.set(session.userId, current + (session.workTimeMs ?? 0)); + } + + // Map members with their total time + const membersWithTime = members.map((member) => ({ + ...member, + totalTimeMs: timePerUser.get(member.User.id) ?? 0, + })); + + // Sort by total time (greatest to smallest), then by role, then by name + const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; + return membersWithTime.sort((a, b) => { + // First sort by total time (descending) + if (b.totalTimeMs !== a.totalTimeMs) { + return b.totalTimeMs - a.totalTimeMs; + } + // Then by role + const roleA = roleOrder[a.OrganisationMember.role] ?? 3; + const roleB = roleOrder[b.OrganisationMember.role] ?? 3; + if (roleA !== roleB) return roleA - roleB; + // Finally by name + return a.User.name.localeCompare(b.User.name); + }); + }, [members, timeTrackingData]); + + // Download time tracking data as CSV or JSON + const downloadTimeTrackingData = (format: "csv" | "json") => { + if (!selectedOrganisation) return; + + // Aggregate data per user + const userData = new Map< + number, + { + userId: number; + name: string; + username: string; + totalTimeMs: number; + sessions: typeof timeTrackingData; + } + >(); + + for (const member of members) { + userData.set(member.User.id, { + userId: member.User.id, + name: member.User.name, + username: member.User.username, + totalTimeMs: 0, + sessions: [], + }); + } + + for (const session of timeTrackingData) { + const user = userData.get(session.userId); + if (user) { + user.totalTimeMs += session.workTimeMs; + user.sessions.push(session); + } + } + + const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs); + + if (format === "csv") { + // Generate CSV + const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"]; + const rows = data.map((user) => [ + user.userId, + user.name, + user.username, + user.totalTimeMs, + formatDuration(user.totalTimeMs), + ]); + const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join( + "\n", + ); + + const blob = new Blob([csv], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } else { + // Generate JSON + const json = JSON.stringify( + { + organisation: selectedOrganisation.Organisation.name, + fromDate: fromDate.toISOString(), + generatedAt: new Date().toISOString(), + members: data.map((user) => ({ + ...user, + totalTimeFormatted: formatDuration(user.totalTimeMs), + })), + }, + null, + 2, + ); + + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + toast.success(`Downloaded time tracking data as ${format.toUpperCase()}`); + }; + const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("info"); @@ -753,12 +885,52 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
-

- {members.length} Member{members.length !== 1 ? "s" : ""} -

+
+

+ {members.length} Member{members.length !== 1 ? "s" : ""} +

+ {isAdmin && ( +
+ + + + + + date && setFromDate(date)} + initialFocus + /> + + + + + + + + downloadTimeTrackingData("csv")}> + + Download CSV + + downloadTimeTrackingData("json")}> + + Download JSON + + + +
+ )} +
- {members.map((member) => ( + {membersWithTimeTracking.map((member) => (
+ {isAdmin && ( + + {formatDuration(member.totalTimeMs)} + + )} {isAdmin && member.OrganisationMember.role !== "owner" && member.User.id !== user.id && ( diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index 1e7e8c0..600cf20 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -69,3 +69,19 @@ export const isLight = (hex: string): boolean => { export const unCamelCase = (str: string): string => { return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase()); }; + +export const formatDuration = (ms: number): string => { + if (ms === 0) return "0s"; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || (hours === 0 && minutes === 0)) parts.push(`${seconds}s`); + + return parts.join(" ") || "0s"; +}; From c8cb99c86a1796f7a4dfe3b63f05663f1b2f48c1 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:16:32 +0000 Subject: [PATCH 06/25] Subscription, Payment and User.plan --- .../backend/drizzle/0026_stale_shocker.sql | 30 + .../backend/drizzle/meta/0026_snapshot.json | 1146 +++++++++++++++++ packages/backend/drizzle/meta/_journal.json | 7 + packages/shared/src/schema.ts | 50 +- 4 files changed, 1232 insertions(+), 1 deletion(-) create mode 100644 packages/backend/drizzle/0026_stale_shocker.sql create mode 100644 packages/backend/drizzle/meta/0026_snapshot.json diff --git a/packages/backend/drizzle/0026_stale_shocker.sql b/packages/backend/drizzle/0026_stale_shocker.sql new file mode 100644 index 0000000..f5d468c --- /dev/null +++ b/packages/backend/drizzle/0026_stale_shocker.sql @@ -0,0 +1,30 @@ +CREATE TABLE "Payment" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Payment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "subscriptionId" integer NOT NULL, + "stripePaymentIntentId" varchar(255), + "amount" integer NOT NULL, + "currency" varchar(3) DEFAULT 'gbp' NOT NULL, + "status" varchar(32) NOT NULL, + "createdAt" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "Subscription" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Subscription_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "userId" integer NOT NULL, + "stripeCustomerId" varchar(255), + "stripeSubscriptionId" varchar(255), + "stripeSubscriptionItemId" varchar(255), + "stripePriceId" varchar(255), + "status" varchar(32) DEFAULT 'incomplete' NOT NULL, + "currentPeriodStart" timestamp, + "currentPeriodEnd" timestamp, + "cancelAtPeriodEnd" boolean DEFAULT false NOT NULL, + "trialEnd" timestamp, + "quantity" integer DEFAULT 1 NOT NULL, + "createdAt" timestamp DEFAULT now(), + "updatedAt" timestamp DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "User" ADD COLUMN "plan" varchar(32) DEFAULT 'free' NOT NULL;--> statement-breakpoint +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_subscriptionId_Subscription_id_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."Subscription"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0026_snapshot.json b/packages/backend/drizzle/meta/0026_snapshot.json new file mode 100644 index 0000000..4a2a899 --- /dev/null +++ b/packages/backend/drizzle/meta/0026_snapshot.json @@ -0,0 +1,1146 @@ +{ + "id": "9104baeb-85d7-4fdb-87cc-9d5ac6b85ec8", + "prevId": "c4f3042a-375d-4d17-b836-3d7816b26519", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index acb30b1..baed3b1 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1769549697892, "tag": "0025_sharp_quicksilver", "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1769615487574, + "tag": "0026_stale_shocker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index dd39647..d433ea6 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -1,4 +1,4 @@ -import { integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { boolean, integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import type { z } from "zod"; import { @@ -59,6 +59,7 @@ export const User = pgTable("User", { passwordHash: varchar({ length: 255 }).notNull(), avatarURL: varchar({ length: 512 }), iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type(), + plan: varchar({ length: 32 }).notNull().default("free"), createdAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); @@ -295,3 +296,50 @@ export type TimerState = { timestamps: string[]; endedAt: string | null; } | null; + +// Subscription table - tracks user subscriptions +export const Subscription = pgTable("Subscription", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer() + .notNull() + .references(() => User.id), + stripeCustomerId: varchar({ length: 255 }), + stripeSubscriptionId: varchar({ length: 255 }), + stripeSubscriptionItemId: varchar({ length: 255 }), + stripePriceId: varchar({ length: 255 }), + status: varchar({ length: 32 }).notNull().default("incomplete"), + currentPeriodStart: timestamp({ withTimezone: false }), + currentPeriodEnd: timestamp({ withTimezone: false }), + cancelAtPeriodEnd: boolean().notNull().default(false), + trialEnd: timestamp({ withTimezone: false }), + quantity: integer().notNull().default(1), + createdAt: timestamp({ withTimezone: false }).defaultNow(), + updatedAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +// Payment history table +export const Payment = pgTable("Payment", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + subscriptionId: integer() + .notNull() + .references(() => Subscription.id), + stripePaymentIntentId: varchar({ length: 255 }), + amount: integer().notNull(), + currency: varchar({ length: 3 }).notNull().default("gbp"), + status: varchar({ length: 32 }).notNull(), + createdAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +// Zod schemas for Subscription and Payment +export const SubscriptionSelectSchema = createSelectSchema(Subscription); +export const SubscriptionInsertSchema = createInsertSchema(Subscription); + +export const PaymentSelectSchema = createSelectSchema(Payment); +export const PaymentInsertSchema = createInsertSchema(Payment); + +// Types for Subscription and Payment +export type SubscriptionRecord = z.infer; +export type SubscriptionInsert = z.infer; + +export type PaymentRecord = z.infer; +export type PaymentInsert = z.infer; From 92ae98279331cb33c30719d89143b26051a30218 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:18:24 +0000 Subject: [PATCH 07/25] stripe client setup --- packages/backend/src/stripe/client.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/backend/src/stripe/client.ts diff --git a/packages/backend/src/stripe/client.ts b/packages/backend/src/stripe/client.ts new file mode 100644 index 0000000..8fab7e0 --- /dev/null +++ b/packages/backend/src/stripe/client.ts @@ -0,0 +1,14 @@ +import Stripe from "stripe"; + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY; + +if (!stripeSecretKey) { + throw new Error("STRIPE_SECRET_KEY is required"); +} + +export const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2024-12-18.acacia", +}); + +export const STRIPE_PRICE_MONTHLY = process.env.STRIPE_PRICE_MONTHLY!; +export const STRIPE_PRICE_ANNUAL = process.env.STRIPE_PRICE_ANNUAL!; From 54b26896d98f92b156e8b8bb264ec3e4a19cb977 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:22:47 +0000 Subject: [PATCH 08/25] database query functions for stripe integration --- .../backend/src/db/queries/subscriptions.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/backend/src/db/queries/subscriptions.ts diff --git a/packages/backend/src/db/queries/subscriptions.ts b/packages/backend/src/db/queries/subscriptions.ts new file mode 100644 index 0000000..389213e --- /dev/null +++ b/packages/backend/src/db/queries/subscriptions.ts @@ -0,0 +1,80 @@ +import { Payment, Subscription } from "@sprint/shared"; +import { eq } from "drizzle-orm"; +import { db } from "../client"; + +export async function createSubscription(data: { + userId: number; + stripeCustomerId: string; + stripeSubscriptionId: string; + stripeSubscriptionItemId: string; + stripePriceId: string; + status: string; + quantity: number; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + trialEnd?: Date; +}) { + const [subscription] = await db + .insert(Subscription) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + return subscription; +} + +export async function getSubscriptionByUserId(userId: number) { + const [subscription] = await db + .select() + .from(Subscription) + .where(eq(Subscription.userId, userId)); + return subscription; +} + +export async function getSubscriptionByStripeId(stripeSubscriptionId: string) { + const [subscription] = await db + .select() + .from(Subscription) + .where(eq(Subscription.stripeSubscriptionId, stripeSubscriptionId)); + return subscription; +} + +export async function updateSubscription( + id: number, + updates: Partial<{ + status: string; + stripePriceId: string; + currentPeriodStart: Date; + currentPeriodEnd: Date; + cancelAtPeriodEnd: boolean; + trialEnd: Date; + quantity: number; + }> +) { + const [subscription] = await db + .update(Subscription) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(Subscription.id, id)) + .returning(); + return subscription; +} + +export async function createPayment(data: { + subscriptionId: number; + stripePaymentIntentId: string; + amount: number; + currency: string; + status: string; +}) { + const [payment] = await db.insert(Payment).values(data).returning(); + return payment; +} + +export async function deleteSubscription(id: number) { + await db.delete(Subscription).where(eq(Subscription.id, id)); +} From 8d623315fbb1f2f0737ef31e2e8906d48f737e8c Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:23:08 +0000 Subject: [PATCH 09/25] updateSeatCount() --- packages/backend/src/lib/seats.ts | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/backend/src/lib/seats.ts diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts new file mode 100644 index 0000000..0f4b563 --- /dev/null +++ b/packages/backend/src/lib/seats.ts @@ -0,0 +1,44 @@ +import { stripe } from "../stripe/client"; +import { getOrganisationsByUserId, getOrganisationMembers } from "../db/queries/organisations"; +import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions"; +import { getUserById } from "../db/queries/users"; + +export async function updateSeatCount(userId: number) { + const user = await getUserById(userId); + + // only update if user has active pro subscription + if (!user || user.plan !== "pro") { + return; + } + + const subscription = await getSubscriptionByUserId(userId); + if (!subscription || subscription.status !== "active") { + return; + } + + // calculate total members across all owned organisations + const organisations = await getOrganisationsByUserId(userId); + const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner"); + + let totalMembers = 0; + for (const org of ownedOrgs) { + const members = await getOrganisationMembers(org.Organisation.id); + totalMembers += members.length; + } + + const newQuantity = Math.max(1, totalMembers - 4); + + // skip if quantity hasn't changed + if (newQuantity === subscription.quantity) { + return; + } + + // update stripe + await stripe.subscriptionItems.update(subscription.stripeSubscriptionItemId!, { + quantity: newQuantity, + proration_behavior: "always_invoice", + }); + + // update local record + await updateSubscription(subscription.id, { quantity: newQuantity }); +} From 6cf7e79f20a64243056ddef6a83f5276c3a47c7a Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:31:34 +0000 Subject: [PATCH 10/25] subscription routes --- .../backend/src/db/queries/subscriptions.ts | 7 +- packages/backend/src/lib/seats.ts | 4 +- .../src/routes/organisation/add-member.ts | 6 + .../src/routes/organisation/remove-member.ts | 6 + .../subscription/create-checkout-session.ts | 81 ++++++++ .../subscription/create-portal-session.ts | 36 ++++ .../backend/src/routes/subscription/get.ts | 25 +++ .../src/routes/subscription/webhook.ts | 179 ++++++++++++++++++ 8 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/routes/subscription/create-checkout-session.ts create mode 100644 packages/backend/src/routes/subscription/create-portal-session.ts create mode 100644 packages/backend/src/routes/subscription/get.ts create mode 100644 packages/backend/src/routes/subscription/webhook.ts diff --git a/packages/backend/src/db/queries/subscriptions.ts b/packages/backend/src/db/queries/subscriptions.ts index 389213e..a6732e3 100644 --- a/packages/backend/src/db/queries/subscriptions.ts +++ b/packages/backend/src/db/queries/subscriptions.ts @@ -26,10 +26,7 @@ export async function createSubscription(data: { } export async function getSubscriptionByUserId(userId: number) { - const [subscription] = await db - .select() - .from(Subscription) - .where(eq(Subscription.userId, userId)); + const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId)); return subscription; } @@ -51,7 +48,7 @@ export async function updateSubscription( cancelAtPeriodEnd: boolean; trialEnd: Date; quantity: number; - }> + }>, ) { const [subscription] = await db .update(Subscription) diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts index 0f4b563..9d4d018 100644 --- a/packages/backend/src/lib/seats.ts +++ b/packages/backend/src/lib/seats.ts @@ -1,7 +1,7 @@ -import { stripe } from "../stripe/client"; -import { getOrganisationsByUserId, getOrganisationMembers } from "../db/queries/organisations"; +import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations"; import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions"; import { getUserById } from "../db/queries/users"; +import { stripe } from "../stripe/client"; export async function updateSeatCount(userId: number) { const user = await getUserById(userId); diff --git a/packages/backend/src/routes/organisation/add-member.ts b/packages/backend/src/routes/organisation/add-member.ts index aec6224..8b60f66 100644 --- a/packages/backend/src/routes/organisation/add-member.ts +++ b/packages/backend/src/routes/organisation/add-member.ts @@ -6,6 +6,7 @@ import { getOrganisationMemberRole, getUserById, } from "../../db/queries"; +import { updateSeatCount } from "../../lib/seats"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function organisationAddMember(req: AuthedRequest) { @@ -40,5 +41,10 @@ export default async function organisationAddMember(req: AuthedRequest) { const member = await createOrganisationMember(organisationId, userId, role); + // update seat count if the requester is the owner + if (requesterMember.role === "owner") { + await updateSeatCount(req.userId); + } + return Response.json(member); } diff --git a/packages/backend/src/routes/organisation/remove-member.ts b/packages/backend/src/routes/organisation/remove-member.ts index 2497768..5d588b8 100644 --- a/packages/backend/src/routes/organisation/remove-member.ts +++ b/packages/backend/src/routes/organisation/remove-member.ts @@ -1,6 +1,7 @@ import { OrgRemoveMemberRequestSchema } from "@sprint/shared"; import type { AuthedRequest } from "../../auth/middleware"; import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries"; +import { updateSeatCount } from "../../lib/seats"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function organisationRemoveMember(req: AuthedRequest) { @@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) { await removeOrganisationMember(organisationId, userId); + // update seat count if the requester is the owner + if (requesterMember.role === "owner") { + await updateSeatCount(req.userId); + } + return Response.json({ success: true }); } diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts new file mode 100644 index 0000000..baef7e2 --- /dev/null +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -0,0 +1,81 @@ +import type { BunRequest } from "bun"; +import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations"; +import { getUserById } from "../../db/queries/users"; +import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client"; +import { errorResponse } from "../../validation"; + +const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; + +async function handler(req: BunRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + try { + const body = await req.json(); + const { billingPeriod } = body as { billingPeriod: "monthly" | "annual" | undefined }; + + if (!billingPeriod) { + return errorResponse("missing required fields", "VALIDATION_ERROR", 400); + } + + const userId = (req as any).userId; + const user = await getUserById(userId); + + if (!user) { + return errorResponse("user not found", "NOT_FOUND", 404); + } + + // calculate seat quantity across all owned organisations + const organisations = await getOrganisationsByUserId(userId); + const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner"); + + let totalMembers = 0; + for (const org of ownedOrgs) { + const members = await getOrganisationMembers(org.Organisation.id); + totalMembers += members.length; + } + + const quantity = Math.max(1, totalMembers - 4); + const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY; + + // build customer data - use username as email if no email field exists + const customerEmail = user.username.includes("@") + ? user.username + : `${user.username}@localhost.local`; + + const session = await stripe.checkout.sessions.create({ + customer_email: customerEmail, + line_items: [ + { + price: priceId, + quantity: quantity, + }, + ], + mode: "subscription", + success_url: `${BASE_URL}/plans?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${BASE_URL}/plans?canceled=true`, + subscription_data: { + metadata: { + userId: userId.toString(), + }, + }, + metadata: { + userId: userId.toString(), + priceId: priceId, + quantity: quantity.toString(), + }, + }); + + return new Response(JSON.stringify({ url: session.url }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("checkout session error:", error); + return errorResponse("failed to create checkout session", "CHECKOUT_ERROR", 500); + } +} + +export default withCors(withAuth(withCSRF(handler))); diff --git a/packages/backend/src/routes/subscription/create-portal-session.ts b/packages/backend/src/routes/subscription/create-portal-session.ts new file mode 100644 index 0000000..2b8f66a --- /dev/null +++ b/packages/backend/src/routes/subscription/create-portal-session.ts @@ -0,0 +1,36 @@ +import type { BunRequest } from "bun"; +import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; +import { stripe } from "../../stripe/client"; +import { errorResponse } from "../../validation"; + +const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; + +async function handler(req: BunRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + try { + const userId = (req as any).userId; + const subscription = await getSubscriptionByUserId(userId); + if (!subscription?.stripeCustomerId) { + return errorResponse("no active subscription found", "NOT_FOUND", 404); + } + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: subscription.stripeCustomerId, + return_url: `${BASE_URL}/plans`, + }); + + return new Response(JSON.stringify({ url: portalSession.url }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("portal session error:", error); + return errorResponse("failed to create portal session", "PORTAL_ERROR", 500); + } +} + +export default withCors(withAuth(withCSRF(handler))); diff --git a/packages/backend/src/routes/subscription/get.ts b/packages/backend/src/routes/subscription/get.ts new file mode 100644 index 0000000..3b0473f --- /dev/null +++ b/packages/backend/src/routes/subscription/get.ts @@ -0,0 +1,25 @@ +import type { BunRequest } from "bun"; +import { withAuth, withCors } from "../../auth/middleware"; +import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; +import { errorResponse } from "../../validation"; + +async function handler(req: BunRequest) { + if (req.method !== "GET") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + try { + const userId = (req as any).userId; + const subscription = await getSubscriptionByUserId(userId); + + return new Response(JSON.stringify({ subscription }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("fetch subscription error:", error); + return errorResponse("failed to fetch subscription", "FETCH_ERROR", 500); + } +} + +export default withCors(withAuth(handler)); diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts new file mode 100644 index 0000000..fc67f0d --- /dev/null +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -0,0 +1,179 @@ +import type { BunRequest } from "bun"; +import type Stripe from "stripe"; +import { + createPayment, + createSubscription, + getSubscriptionByStripeId, + updateSubscription, +} from "../../db/queries/subscriptions"; +import { updateUser } from "../../db/queries/users"; +import { stripe } from "../../stripe/client"; + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || ""; + +export default async function webhook(req: BunRequest) { + if (req.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + + const payload = await req.text(); + const signature = req.headers.get("stripe-signature"); + + if (!signature) { + return new Response("Missing signature", { status: 400 }); + } + + let event: Stripe.Event; + + try { + // use async version for Bun compatibility + event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret); + } catch (err) { + console.error("webhook signature verification failed:", err); + return new Response("Invalid signature", { status: 400 }); + } + + try { + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session; + + if (session.mode !== "subscription" || !session.subscription) { + break; + } + + const userId = parseInt(session.metadata?.userId || "0", 10); + if (!userId) { + console.error("missing userId in session metadata"); + break; + } + + // fetch full subscription to get item id + const stripeSubscription = await stripe.subscriptions.retrieve( + session.subscription as string, + ); + if (!stripeSubscription) { + console.error("failed to retrieve subscription:", session.subscription); + break; + } + if (!stripeSubscription.items.data[0]) { + console.error("subscription has no items:", stripeSubscription.id); + break; + } + + await createSubscription({ + userId, + stripeCustomerId: session.customer as string, + stripeSubscriptionId: stripeSubscription.id, + stripeSubscriptionItemId: stripeSubscription.items.data[0].id, + stripePriceId: session.metadata?.priceId || "", + status: stripeSubscription.status, + quantity: parseInt(session.metadata?.quantity || "1", 10), + currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000), + currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000), + trialEnd: stripeSubscription.trial_end + ? new Date(stripeSubscription.trial_end * 1000) + : undefined, + }); + + await updateUser(userId, { plan: "pro" }); + + console.log(`subscription activated for user ${userId}`); + break; + } + + case "customer.subscription.updated": { + const subscription = event.data.object as Stripe.Subscription; + if (!subscription) { + console.error("failed to retrieve subscription (customer.subscription.updated)"); + break; + } + if (!subscription.items.data[0]) { + console.error("subscription has no items:", subscription.id); + break; + } + + const localSub = await getSubscriptionByStripeId(subscription.id); + if (!localSub) { + console.error("subscription not found:", subscription.id); + break; + } + // safely convert timestamps to dates + const currentPeriodStart = (subscription as any).current_period_start + ? new Date((subscription as any).current_period_start * 1000) + : undefined; + const currentPeriodEnd = (subscription as any).current_period_end + ? new Date((subscription as any).current_period_end * 1000) + : undefined; + + await updateSubscription(localSub.id, { + status: subscription.status, + ...(currentPeriodStart && { currentPeriodStart }), + ...(currentPeriodEnd && { currentPeriodEnd }), + cancelAtPeriodEnd: subscription.cancel_at_period_end, + quantity: subscription.items.data[0].quantity || 1, + }); + + console.log(`subscription updated: ${subscription.id}`); + break; + } + + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + + const localSub = await getSubscriptionByStripeId(subscription.id); + if (!localSub) break; + + // delete subscription from database + const { deleteSubscription } = await import("../../db/queries/subscriptions"); + await deleteSubscription(localSub.id); + await updateUser(localSub.userId, { plan: "free" }); + + console.log(`subscription deleted: ${subscription.id}`); + break; + } + + case "invoice.payment_succeeded": { + const invoice = event.data.object as Stripe.Invoice; + + if (!(invoice as any).subscription) break; + + const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!localSub) break; + + await createPayment({ + subscriptionId: localSub.id, + stripePaymentIntentId: (invoice as any).payment_intent as string, + amount: invoice.amount_paid, + currency: invoice.currency, + status: "succeeded", + }); + + console.log(`payment recorded for subscription ${(invoice as any).subscription}`); + break; + } + + case "invoice.payment_failed": { + const invoice = event.data.object as Stripe.Invoice; + + if (!(invoice as any).subscription) break; + + const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!localSub) break; + + await updateSubscription(localSub.id, { status: "past_due" }); + + console.log(`payment failed for subscription ${(invoice as any).subscription}`); + break; + } + } + + return new Response(JSON.stringify({ received: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("webhook processing error:", error); + return new Response("Webhook handler failed", { status: 500 }); + } +} From 98ff4014cc5920af329b63b969c67e2c2a49c53f Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:33:30 +0000 Subject: [PATCH 11/25] stripe frontend --- packages/backend/src/db/queries/users.ts | 6 + packages/frontend/src/components/account.tsx | 16 +- .../frontend/src/components/pricing-card.tsx | 120 +++++++++ packages/frontend/src/components/top-bar.tsx | 8 +- .../subscription/createCheckoutSession.ts | 38 +++ .../subscription/createPortalSession.ts | 31 +++ .../server/subscription/getSubscription.ts | 27 +++ packages/frontend/src/main.tsx | 18 ++ packages/frontend/src/pages/Landing.tsx | 120 +-------- packages/frontend/src/pages/Plans.tsx | 229 ++++++++++++++++++ 10 files changed, 504 insertions(+), 109 deletions(-) create mode 100644 packages/frontend/src/components/pricing-card.tsx create mode 100644 packages/frontend/src/lib/server/subscription/createCheckoutSession.ts create mode 100644 packages/frontend/src/lib/server/subscription/createPortalSession.ts create mode 100644 packages/frontend/src/lib/server/subscription/getSubscription.ts create mode 100644 packages/frontend/src/pages/Plans.tsx diff --git a/packages/backend/src/db/queries/users.ts b/packages/backend/src/db/queries/users.ts index bd034c6..83f1fc6 100644 --- a/packages/backend/src/db/queries/users.ts +++ b/packages/backend/src/db/queries/users.ts @@ -29,8 +29,14 @@ export async function updateById( passwordHash?: string; avatarURL?: string | null; iconPreference?: IconStyle; + plan?: string; }, ): Promise { const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); return user; } + +export async function updateUser(id: number, updates: { plan?: string }) { + const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); + return user; +} diff --git a/packages/frontend/src/components/account.tsx b/packages/frontend/src/components/account.tsx index 5ebe4f1..192e5a1 100644 --- a/packages/frontend/src/components/account.tsx +++ b/packages/frontend/src/components/account.tsx @@ -1,6 +1,7 @@ import type { IconStyle } from "@sprint/shared"; import type { ReactNode } from "react"; import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { toast } from "sonner"; import { useAuthenticatedSession } from "@/components/session-provider"; import ThemeToggle from "@/components/theme-toggle"; @@ -161,7 +162,20 @@ function Account({ trigger }: { trigger?: ReactNode }) { {error !== "" && } -
+ {/* Show subscription management link */} +
+ {currentUser.plan === "pro" ? ( + + ) : ( + + )} +
+ +
diff --git a/packages/frontend/src/components/pricing-card.tsx b/packages/frontend/src/components/pricing-card.tsx new file mode 100644 index 0000000..0fe2c22 --- /dev/null +++ b/packages/frontend/src/components/pricing-card.tsx @@ -0,0 +1,120 @@ +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { cn } from "@/lib/utils"; + +export interface PricingTier { + name: string; + price: string; + priceAnnual: string; + period: string; + periodAnnual: string; + description: string; + tagline: string; + features: string[]; + cta: string; + highlighted: boolean; +} + +export function PricingCard({ + tier, + billingPeriod, + onCtaClick, + disabled = false, + loading = false, +}: { + tier: PricingTier; + billingPeriod: "monthly" | "annual"; + onCtaClick: () => void; + disabled?: boolean; + loading?: boolean; +}) { + return ( +
+ {tier.highlighted && ( +
+ {tier.tagline} +
+ )} + +
+

{tier.name}

+
+ + {billingPeriod === "annual" ? tier.priceAnnual : tier.price} + + + {billingPeriod === "annual" ? tier.periodAnnual : tier.period} + +
+

{tier.description}

+
+ +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + +
+ ); +} + +export const pricingTiers: PricingTier[] = [ + { + name: "Starter", + price: "£0", + priceAnnual: "£0", + period: "Free forever", + periodAnnual: "Free forever", + description: "Perfect for side projects and solo developers", + tagline: "For solo devs and small projects", + features: [ + "1 organisation (owned or joined)", + "1 project", + "100 issues", + "Up to 5 team members", + "Email support", + ], + cta: "Get started free", + highlighted: false, + }, + { + name: "Pro", + price: "£11.99", + priceAnnual: "£9.99", + period: "per user/month", + periodAnnual: "per user/month", + description: "For growing teams and professionals", + tagline: "Most Popular", + features: [ + "Everything in starter", + "Unlimited organisations", + "Unlimited projects", + "Unlimited issues", + "Advanced time tracking & reports", + "Custom issue statuses", + "Priority email support", + ], + cta: "Upgrade to Pro", + highlighted: true, + }, +]; diff --git a/packages/frontend/src/components/top-bar.tsx b/packages/frontend/src/components/top-bar.tsx index fabc10e..a47e8e3 100644 --- a/packages/frontend/src/components/top-bar.tsx +++ b/packages/frontend/src/components/top-bar.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import Account from "@/components/account"; import { IssueForm } from "@/components/issue-form"; import LogOutButton from "@/components/log-out-button"; @@ -11,6 +11,7 @@ import { useSelection } from "@/components/selection-provider"; import { useAuthenticatedSession } from "@/components/session-provider"; import SmallUserDisplay from "@/components/small-user-display"; import { SprintForm } from "@/components/sprint-form"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -122,6 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole )}
+ {user.plan !== "pro" && ( + + )} diff --git a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts new file mode 100644 index 0000000..a6e88eb --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts @@ -0,0 +1,38 @@ +import { getServerURL } from "@/lib/utils"; + +interface CreateCheckoutParams { + billingPeriod: "monthly" | "annual"; + csrfToken: string; + onSuccess?: (url: string) => void; + onError?: (error: string) => void; +} + +export async function createCheckoutSession({ + billingPeriod, + csrfToken, + onSuccess, + onError, +}: CreateCheckoutParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/create-checkout-session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + body: JSON.stringify({ billingPeriod }), + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to create checkout session"); + return; + } + + onSuccess?.(data.url); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/lib/server/subscription/createPortalSession.ts b/packages/frontend/src/lib/server/subscription/createPortalSession.ts new file mode 100644 index 0000000..9395f19 --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/createPortalSession.ts @@ -0,0 +1,31 @@ +import { getServerURL } from "@/lib/utils"; + +interface CreatePortalParams { + csrfToken: string; + onSuccess?: (url: string) => void; + onError?: (error: string) => void; +} + +export async function createPortalSession({ csrfToken, onSuccess, onError }: CreatePortalParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/create-portal-session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to create portal session"); + return; + } + + onSuccess?.(data.url); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/lib/server/subscription/getSubscription.ts b/packages/frontend/src/lib/server/subscription/getSubscription.ts new file mode 100644 index 0000000..e23d3c3 --- /dev/null +++ b/packages/frontend/src/lib/server/subscription/getSubscription.ts @@ -0,0 +1,27 @@ +import type { SubscriptionRecord } from "@sprint/shared"; +import { getServerURL } from "@/lib/utils"; + +interface GetSubscriptionParams { + onSuccess?: (subscription: SubscriptionRecord | null) => void; + onError?: (error: string) => void; +} + +export async function getSubscription({ onSuccess, onError }: GetSubscriptionParams) { + try { + const response = await fetch(`${getServerURL()}/subscription/get`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + onError?.(data.error || "Failed to fetch subscription"); + return; + } + + onSuccess?.(data.subscription); + } catch (error) { + onError?.("Network error"); + } +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 8a7761d..e0d83d3 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -12,6 +12,8 @@ import Font from "@/pages/Font"; import Issues from "@/pages/Issues"; import Landing from "@/pages/Landing"; import NotFound from "@/pages/NotFound"; +import Plans from "@/pages/Plans"; +import StripeTest from "@/pages/StripeTest"; import Test from "@/pages/Test"; import Timeline from "@/pages/Timeline"; @@ -28,6 +30,14 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> {/* authed routes */} + + + + } + /> } /> + + + + } + /> ("monthly"); + const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); const [loginModalOpen, setLoginModalOpen] = useState(false); return ( @@ -163,7 +114,7 @@ export default function Landing() { ) : ( <>
-

No credit card required · Full access for 14 days

+

Free forever · Upgrade when you need more

{/* problem section */} @@ -340,57 +291,12 @@ export default function Landing() {
{pricingTiers.map((tier) => ( -
- {tier.highlighted && ( -
- {tier.tagline} -
- )} - -
-

{tier.name}

-
- - {billingPeriod === "annual" ? tier.priceAnnual : tier.price} - - - {billingPeriod === "annual" ? tier.periodAnnual : tier.period} - -
-

{tier.description}

-
- -
    - {tier.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- - -
+ tier={tier} + billingPeriod={billingPeriod} + onCtaClick={() => setLoginModalOpen(true)} + /> ))}
@@ -408,8 +314,8 @@ export default function Landing() { className="size-8" color="var(--personality)" /> -

No Card Required

-

Start your trial instantly

+

Free Starter Plan

+

Get started instantly

@@ -473,12 +379,12 @@ export default function Landing() { ) : ( )}

- No credit card required · 14-day free trial · Cancel anytime + Free forever · Upgrade when you need more · Cancel anytime

diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx new file mode 100644 index 0000000..62d146a --- /dev/null +++ b/packages/frontend/src/pages/Plans.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { LoginModal } from "@/components/login-modal"; +import { PricingCard, pricingTiers } from "@/components/pricing-card"; +import { useSession } from "@/components/session-provider"; +import { Button } from "@/components/ui/button"; +import Icon from "@/components/ui/icon"; +import { Switch } from "@/components/ui/switch"; +import { createCheckoutSession } from "@/lib/server/subscription/createCheckoutSession"; +import { createPortalSession } from "@/lib/server/subscription/createPortalSession"; +import { getSubscription } from "@/lib/server/subscription/getSubscription"; +import { cn, getCsrfToken } from "@/lib/utils"; + +interface SubscriptionData { + status: string; + currentPeriodEnd: Date | null; + quantity: number; +} + +export default function Plans() { + const { user, isLoading } = useSession(); + const navigate = useNavigate(); + const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); + const [loginModalOpen, setLoginModalOpen] = useState(false); + const [subscription, setSubscription] = useState(null); + const [loadingSubscription, setLoadingSubscription] = useState(false); + const [processingTier, setProcessingTier] = useState(null); + + // fetch subscription if user is logged in + useEffect(() => { + if (user) { + setLoadingSubscription(true); + getSubscription({ + onSuccess: (data) => { + setSubscription(data); + setLoadingSubscription(false); + }, + onError: () => { + setSubscription(null); + setLoadingSubscription(false); + }, + }); + } + }, [user]); + + const hasProSubscription = subscription?.status === "active"; + const csrfToken = getCsrfToken() || ""; + + const handleTierAction = (tierName: string) => { + if (!user) { + setLoginModalOpen(true); + return; + } + + if (tierName === "Pro") { + if (hasProSubscription) { + // open customer portal + setProcessingTier(tierName); + createPortalSession({ + csrfToken, + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => { + setProcessingTier(null); + }, + }); + } else { + // start checkout + setProcessingTier(tierName); + createCheckoutSession({ + billingPeriod, + csrfToken, + onSuccess: (url) => { + window.location.href = url; + }, + onError: () => { + setProcessingTier(null); + }, + }); + } + } + // starter tier - just go to issues if not already there + if (tierName === "Starter") { + navigate("/issues"); + } + }; + + // modify pricing tiers based on user's current plan + const modifiedTiers = pricingTiers.map((tier) => { + const isCurrentPlan = tier.name === "Pro" && hasProSubscription; + const isStarterCurrent = tier.name === "Starter" && !hasProSubscription; + + return { + ...tier, + highlighted: isCurrentPlan || (!hasProSubscription && tier.name === "Pro"), + cta: isCurrentPlan + ? "Manage subscription" + : isStarterCurrent + ? "Current plan" + : tier.name === "Pro" + ? "Upgrade to Pro" + : tier.cta, + }; + }); + + return ( +
+
+
+
+ Sprint + Sprint +
+ +
+
+ +
+
+
+

+ {user ? "Choose your plan" : "Simple, transparent pricing"} +

+

+ {user + ? hasProSubscription + ? "You are currently on the Pro plan. Manage your subscription or switch plans below." + : "You are currently on the Starter plan. Upgrade to Pro for unlimited access." + : "Choose the plan that fits your team. Scale as you grow."} +

+ + {/* billing toggle */} +
+ + setBillingPeriod(checked ? "annual" : "monthly")} + className="bg-border data-[state=checked]:bg-border! data-[state=unchecked]:bg-border!" + thumbClassName="bg-personality dark:bg-personality data-[state=checked]:bg-personality! data-[state=unchecked]:bg-personality!" + aria-label="toggle billing period" + /> + + + Save 17% + +
+
+ +
+ {modifiedTiers.map((tier) => ( + handleTierAction(tier.name)} + disabled={processingTier !== null || tier.name === "Starter"} + loading={processingTier === tier.name} + /> + ))} +
+ + {/* trust signals */} +
+
+ +

Secure & Encrypted

+

Your data is safe with us

+
+
+ +

Free Starter Plan

+

Get started instantly

+
+
+ +

Money Back Guarantee

+

30-day no-risk policy

+
+
+
+
+ + +
+ ); +} From f70a088a294ca90707fe0acc59a553128abcbe27 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:33:58 +0000 Subject: [PATCH 12/25] stripe package --- bun.lock | 45 +++++++++++++++++++++++++++++++++-- packages/backend/.env.example | 5 +++- packages/backend/package.json | 1 + 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index c15f919..6224f0d 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", "sharp": "^0.34.5", + "stripe": "^20.2.0", "zod": "^3.23.8", }, "devDependencies": { @@ -482,7 +483,7 @@ "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -516,7 +517,11 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="], @@ -558,6 +563,8 @@ "drizzle-zod": ["drizzle-zod@0.5.1", "", { "peerDependencies": { "drizzle-orm": ">=0.23.13", "zod": "*" } }, "sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], @@ -566,6 +573,12 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], @@ -576,18 +589,30 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -648,6 +673,8 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -660,6 +687,8 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "pg": ["pg@8.17.1", "", { "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], @@ -690,6 +719,8 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], @@ -732,6 +763,14 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -746,6 +785,8 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "stripe": ["stripe@20.2.0", "", { "dependencies": { "qs": "^6.14.1" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 068b4b9..00fb588 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -10,4 +10,7 @@ S3_PUBLIC_URL=https://issuebucket.ob248.com S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/issue S3_ACCESS_KEY_ID=your_access_key_id S3_SECRET_ACCESS_KEY=your_secret_access_key -S3_BUCKET_NAME=issue \ No newline at end of file +S3_BUCKET_NAME=issue + +STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key +STRIPE_SECRET_KEY=your_stripe_secret_key \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index a0c62e5..91cc33d 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -34,6 +34,7 @@ "jsonwebtoken": "^9.0.3", "pg": "^8.16.3", "sharp": "^0.34.5", + "stripe": "^20.2.0", "zod": "^3.23.8" } } From db2c1dddfe57b9c8c132ced488ab05409c4dd8be Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:35:00 +0000 Subject: [PATCH 13/25] more stripe changes on frontend --- packages/backend/src/db/queries/index.ts | 1 + .../organisation/member-time-tracking.ts | 8 ++------ .../frontend/src/components/organisations.tsx | 20 ++++--------------- packages/shared/src/api-schemas.ts | 1 + 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/db/queries/index.ts b/packages/backend/src/db/queries/index.ts index 99988cb..8b5b9e2 100644 --- a/packages/backend/src/db/queries/index.ts +++ b/packages/backend/src/db/queries/index.ts @@ -4,5 +4,6 @@ export * from "./organisations"; export * from "./projects"; export * from "./sessions"; export * from "./sprints"; +export * from "./subscriptions"; export * from "./timed-sessions"; export * from "./users"; diff --git a/packages/backend/src/routes/organisation/member-time-tracking.ts b/packages/backend/src/routes/organisation/member-time-tracking.ts index 410376d..c5813ba 100644 --- a/packages/backend/src/routes/organisation/member-time-tracking.ts +++ b/packages/backend/src/routes/organisation/member-time-tracking.ts @@ -21,13 +21,12 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest) const { organisationId, fromDate } = parsed.data; - // Check organisation exists + // check organisation exists const organisation = await getOrganisationById(organisationId); if (!organisation) { return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404); } - // Check user is admin or owner of the organisation const memberRole = await getOrganisationMemberRole(organisationId, req.userId); if (!memberRole) { return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403); @@ -38,11 +37,8 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest) return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403); } - // Get timed sessions for all organisation members const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate); - // Enrich with calculated times - // timestamps come from the database as strings, need to convert to Date objects for calculation const enriched = sessions.map((session) => { const timestamps = session.timestamps.map((t) => new Date(t)); return { @@ -51,7 +47,7 @@ export default async function organisationMemberTimeTracking(req: AuthedRequest) issueId: session.issueId, issueNumber: session.issueNumber, projectKey: session.projectKey, - timestamps: session.timestamps, // Return original strings for JSON serialization + timestamps: session.timestamps, endedAt: session.endedAt, createdAt: session.createdAt, workTimeMs: calculateWorkTimeMs(timestamps), diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index 6eda4e7..fac2d29 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -126,42 +126,33 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { }); }, [membersData]); - // Calculate total time per member and sort by time (greatest to smallest) const membersWithTimeTracking = useMemo(() => { - // Calculate total time per user const timePerUser = new Map(); for (const session of timeTrackingData) { const current = timePerUser.get(session.userId) ?? 0; timePerUser.set(session.userId, current + (session.workTimeMs ?? 0)); } - // Map members with their total time const membersWithTime = members.map((member) => ({ ...member, totalTimeMs: timePerUser.get(member.User.id) ?? 0, })); - // Sort by total time (greatest to smallest), then by role, then by name const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; return membersWithTime.sort((a, b) => { - // First sort by total time (descending) if (b.totalTimeMs !== a.totalTimeMs) { return b.totalTimeMs - a.totalTimeMs; } - // Then by role const roleA = roleOrder[a.OrganisationMember.role] ?? 3; const roleB = roleOrder[b.OrganisationMember.role] ?? 3; if (roleA !== roleB) return roleA - roleB; - // Finally by name return a.User.name.localeCompare(b.User.name); }); }, [members, timeTrackingData]); - // Download time tracking data as CSV or JSON const downloadTimeTrackingData = (format: "csv" | "json") => { if (!selectedOrganisation) return; - // Aggregate data per user const userData = new Map< number, { @@ -193,8 +184,8 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs); + // generate CSV or JSON if (format === "csv") { - // Generate CSV const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"]; const rows = data.map((user) => [ user.userId, @@ -207,6 +198,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { "\n", ); + // download const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -217,7 +209,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { document.body.removeChild(a); URL.revokeObjectURL(url); } else { - // Generate JSON const json = JSON.stringify( { organisation: selectedOrganisation.Organisation.name, @@ -232,6 +223,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { 2, ); + // download const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -894,7 +886,6 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { @@ -903,24 +894,21 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { mode="single" selected={fromDate} onSelect={(date) => date && setFromDate(date)} - initialFocus + autoFocus /> downloadTimeTrackingData("csv")}> - Download CSV downloadTimeTrackingData("json")}> - Download JSON diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index 029a7cf..e6ac4d0 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -419,6 +419,7 @@ export const UserResponseSchema = z.object({ username: z.string(), avatarURL: z.string().nullable(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]), + plan: z.string().nullable().optional(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }); From 260d0558eff591bc30d418903154ff834789a88c Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 18:51:52 +0000 Subject: [PATCH 14/25] 5 seats for free --- packages/backend/src/lib/seats.ts | 2 +- .../backend/src/routes/subscription/create-checkout-session.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts index 9d4d018..c1266ad 100644 --- a/packages/backend/src/lib/seats.ts +++ b/packages/backend/src/lib/seats.ts @@ -26,7 +26,7 @@ export async function updateSeatCount(userId: number) { totalMembers += members.length; } - const newQuantity = Math.max(1, totalMembers - 4); + const newQuantity = Math.max(1, totalMembers - 5); // skip if quantity hasn't changed if (newQuantity === subscription.quantity) { diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts index baef7e2..4faf1bc 100644 --- a/packages/backend/src/routes/subscription/create-checkout-session.ts +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -37,7 +37,7 @@ async function handler(req: BunRequest) { totalMembers += members.length; } - const quantity = Math.max(1, totalMembers - 4); + const quantity = Math.max(1, totalMembers - 5); const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY; // build customer data - use username as email if no email field exists From 99987e35bb7069c2891d1bf50eedc953d86dd4cb Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 20:06:39 +0000 Subject: [PATCH 15/25] fixed to use hooks --- packages/backend/src/lib/seats.ts | 7 +- .../subscription/create-checkout-session.ts | 7 +- .../subscription/create-portal-session.ts | 7 +- .../backend/src/routes/subscription/get.ts | 7 +- .../src/routes/subscription/webhook.ts | 63 ++++++++---- packages/backend/src/stripe/client.ts | 20 ++-- .../frontend/src/lib/query/hooks/index.ts | 1 + .../src/lib/query/hooks/subscriptions.ts | 44 +++++++++ packages/frontend/src/lib/query/keys.ts | 4 + .../subscription/createCheckoutSession.ts | 38 -------- .../subscription/createPortalSession.ts | 31 ------ .../server/subscription/getSubscription.ts | 27 ------ packages/frontend/src/pages/Plans.tsx | 96 +++++++++++-------- packages/shared/src/api-schemas.ts | 45 +++++++++ packages/shared/src/contract.ts | 36 +++++++ packages/shared/src/index.ts | 12 ++- 16 files changed, 270 insertions(+), 175 deletions(-) create mode 100644 packages/frontend/src/lib/query/hooks/subscriptions.ts delete mode 100644 packages/frontend/src/lib/server/subscription/createCheckoutSession.ts delete mode 100644 packages/frontend/src/lib/server/subscription/createPortalSession.ts delete mode 100644 packages/frontend/src/lib/server/subscription/getSubscription.ts diff --git a/packages/backend/src/lib/seats.ts b/packages/backend/src/lib/seats.ts index c1266ad..6f444a4 100644 --- a/packages/backend/src/lib/seats.ts +++ b/packages/backend/src/lib/seats.ts @@ -33,8 +33,13 @@ export async function updateSeatCount(userId: number) { return; } + const stripeSubscriptionItemId = subscription.stripeSubscriptionItemId; + if (!stripeSubscriptionItemId) { + return; + } + // update stripe - await stripe.subscriptionItems.update(subscription.stripeSubscriptionItemId!, { + await stripe.subscriptionItems.update(stripeSubscriptionItemId, { quantity: newQuantity, proration_behavior: "always_invoice", }); diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts index 4faf1bc..b0e72d0 100644 --- a/packages/backend/src/routes/subscription/create-checkout-session.ts +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -1,5 +1,4 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations"; import { getUserById } from "../../db/queries/users"; import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client"; @@ -7,7 +6,7 @@ import { errorResponse } from "../../validation"; const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "POST") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } @@ -20,7 +19,7 @@ async function handler(req: BunRequest) { return errorResponse("missing required fields", "VALIDATION_ERROR", 400); } - const userId = (req as any).userId; + const { userId } = req; const user = await getUserById(userId); if (!user) { diff --git a/packages/backend/src/routes/subscription/create-portal-session.ts b/packages/backend/src/routes/subscription/create-portal-session.ts index 2b8f66a..09beeb9 100644 --- a/packages/backend/src/routes/subscription/create-portal-session.ts +++ b/packages/backend/src/routes/subscription/create-portal-session.ts @@ -1,18 +1,17 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; import { stripe } from "../../stripe/client"; import { errorResponse } from "../../validation"; const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "POST") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } try { - const userId = (req as any).userId; + const { userId } = req; const subscription = await getSubscriptionByUserId(userId); if (!subscription?.stripeCustomerId) { return errorResponse("no active subscription found", "NOT_FOUND", 404); diff --git a/packages/backend/src/routes/subscription/get.ts b/packages/backend/src/routes/subscription/get.ts index 3b0473f..59abaa5 100644 --- a/packages/backend/src/routes/subscription/get.ts +++ b/packages/backend/src/routes/subscription/get.ts @@ -1,15 +1,14 @@ -import type { BunRequest } from "bun"; -import { withAuth, withCors } from "../../auth/middleware"; +import { type AuthedRequest, withAuth, withCors } from "../../auth/middleware"; import { getSubscriptionByUserId } from "../../db/queries/subscriptions"; import { errorResponse } from "../../validation"; -async function handler(req: BunRequest) { +async function handler(req: AuthedRequest) { if (req.method !== "GET") { return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); } try { - const userId = (req as any).userId; + const { userId } = req; const subscription = await getSubscriptionByUserId(userId); return new Response(JSON.stringify({ subscription }), { diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts index fc67f0d..4040ea5 100644 --- a/packages/backend/src/routes/subscription/webhook.ts +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -9,7 +9,15 @@ import { import { updateUser } from "../../db/queries/users"; import { stripe } from "../../stripe/client"; -const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || ""; +const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET"); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} export default async function webhook(req: BunRequest) { if (req.method !== "POST") { @@ -61,6 +69,13 @@ export default async function webhook(req: BunRequest) { break; } + // stripe types use snake_case for these fields + const sub = stripeSubscription as unknown as { + current_period_start: number; + current_period_end: number; + trial_end: number | null; + }; + await createSubscription({ userId, stripeCustomerId: session.customer as string, @@ -69,11 +84,9 @@ export default async function webhook(req: BunRequest) { stripePriceId: session.metadata?.priceId || "", status: stripeSubscription.status, quantity: parseInt(session.metadata?.quantity || "1", 10), - currentPeriodStart: new Date((stripeSubscription as any).current_period_start * 1000), - currentPeriodEnd: new Date((stripeSubscription as any).current_period_end * 1000), - trialEnd: stripeSubscription.trial_end - ? new Date(stripeSubscription.trial_end * 1000) - : undefined, + currentPeriodStart: new Date(sub.current_period_start * 1000), + currentPeriodEnd: new Date(sub.current_period_end * 1000), + trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : undefined, }); await updateUser(userId, { plan: "pro" }); @@ -99,11 +112,16 @@ export default async function webhook(req: BunRequest) { break; } // safely convert timestamps to dates - const currentPeriodStart = (subscription as any).current_period_start - ? new Date((subscription as any).current_period_start * 1000) + // stripe types use snake_case for these fields + const sub = subscription as unknown as { + current_period_start: number | null; + current_period_end: number | null; + }; + const currentPeriodStart = sub.current_period_start + ? new Date(sub.current_period_start * 1000) : undefined; - const currentPeriodEnd = (subscription as any).current_period_end - ? new Date((subscription as any).current_period_end * 1000) + const currentPeriodEnd = sub.current_period_end + ? new Date(sub.current_period_end * 1000) : undefined; await updateSubscription(localSub.id, { @@ -136,34 +154,45 @@ export default async function webhook(req: BunRequest) { case "invoice.payment_succeeded": { const invoice = event.data.object as Stripe.Invoice; - if (!(invoice as any).subscription) break; + // stripe types use snake_case for these fields + const inv = invoice as unknown as { + subscription: string | null; + payment_intent: string | null; + }; - const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!inv.subscription) break; + + const localSub = await getSubscriptionByStripeId(inv.subscription); if (!localSub) break; await createPayment({ subscriptionId: localSub.id, - stripePaymentIntentId: (invoice as any).payment_intent as string, + stripePaymentIntentId: inv.payment_intent || "", amount: invoice.amount_paid, currency: invoice.currency, status: "succeeded", }); - console.log(`payment recorded for subscription ${(invoice as any).subscription}`); + console.log(`payment recorded for subscription ${inv.subscription}`); break; } case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; - if (!(invoice as any).subscription) break; + // stripe types use snake_case for these fields + const inv = invoice as unknown as { + subscription: string | null; + }; - const localSub = await getSubscriptionByStripeId((invoice as any).subscription as string); + if (!inv.subscription) break; + + const localSub = await getSubscriptionByStripeId(inv.subscription); if (!localSub) break; await updateSubscription(localSub.id, { status: "past_due" }); - console.log(`payment failed for subscription ${(invoice as any).subscription}`); + console.log(`payment failed for subscription ${inv.subscription}`); break; } } diff --git a/packages/backend/src/stripe/client.ts b/packages/backend/src/stripe/client.ts index 8fab7e0..1700678 100644 --- a/packages/backend/src/stripe/client.ts +++ b/packages/backend/src/stripe/client.ts @@ -1,14 +1,18 @@ import Stripe from "stripe"; -const stripeSecretKey = process.env.STRIPE_SECRET_KEY; - -if (!stripeSecretKey) { - throw new Error("STRIPE_SECRET_KEY is required"); -} +const stripeSecretKey = requireEnv("STRIPE_SECRET_KEY"); export const stripe = new Stripe(stripeSecretKey, { - apiVersion: "2024-12-18.acacia", + apiVersion: "2025-12-15.clover", }); -export const STRIPE_PRICE_MONTHLY = process.env.STRIPE_PRICE_MONTHLY!; -export const STRIPE_PRICE_ANNUAL = process.env.STRIPE_PRICE_ANNUAL!; +export const STRIPE_PRICE_MONTHLY = requireEnv("STRIPE_PRICE_MONTHLY"); +export const STRIPE_PRICE_ANNUAL = requireEnv("STRIPE_PRICE_ANNUAL"); + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index c548710..b998c4e 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -4,5 +4,6 @@ export * from "@/lib/query/hooks/issues"; export * from "@/lib/query/hooks/organisations"; export * from "@/lib/query/hooks/projects"; export * from "@/lib/query/hooks/sprints"; +export * from "@/lib/query/hooks/subscriptions"; export * from "@/lib/query/hooks/timers"; export * from "@/lib/query/hooks/users"; diff --git a/packages/frontend/src/lib/query/hooks/subscriptions.ts b/packages/frontend/src/lib/query/hooks/subscriptions.ts new file mode 100644 index 0000000..1ad0e29 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/subscriptions.ts @@ -0,0 +1,44 @@ +import type { + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + CreatePortalSessionResponse, + GetSubscriptionResponse, +} from "@sprint/shared"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query/keys"; +import { apiClient } from "@/lib/server"; + +export function useSubscription() { + return useQuery({ + queryKey: queryKeys.subscription.current(), + queryFn: async () => { + const { data, error } = await apiClient.subscriptionGet(); + if (error) throw new Error(error); + return (data ?? { subscription: null }) as GetSubscriptionResponse; + }, + }); +} + +export function useCreateCheckoutSession() { + return useMutation({ + mutationKey: ["subscription", "checkout"], + mutationFn: async (input) => { + const { data, error } = await apiClient.subscriptionCreateCheckoutSession({ body: input }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to create checkout session"); + return data as CreateCheckoutSessionResponse; + }, + }); +} + +export function useCreatePortalSession() { + return useMutation({ + mutationKey: ["subscription", "portal"], + mutationFn: async () => { + const { data, error } = await apiClient.subscriptionCreatePortalSession({ body: {} }); + if (error) throw new Error(error); + if (!data) throw new Error("failed to create portal session"); + return data as CreatePortalSessionResponse; + }, + }); +} diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts index 2c5903e..19f3782 100644 --- a/packages/frontend/src/lib/query/keys.ts +++ b/packages/frontend/src/lib/query/keys.ts @@ -39,4 +39,8 @@ export const queryKeys = { all: ["users"] as const, byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const, }, + subscription: { + all: ["subscription"] as const, + current: () => [...queryKeys.subscription.all, "current"] as const, + }, }; diff --git a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts b/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts deleted file mode 100644 index a6e88eb..0000000 --- a/packages/frontend/src/lib/server/subscription/createCheckoutSession.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getServerURL } from "@/lib/utils"; - -interface CreateCheckoutParams { - billingPeriod: "monthly" | "annual"; - csrfToken: string; - onSuccess?: (url: string) => void; - onError?: (error: string) => void; -} - -export async function createCheckoutSession({ - billingPeriod, - csrfToken, - onSuccess, - onError, -}: CreateCheckoutParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/create-checkout-session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - body: JSON.stringify({ billingPeriod }), - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to create checkout session"); - return; - } - - onSuccess?.(data.url); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/lib/server/subscription/createPortalSession.ts b/packages/frontend/src/lib/server/subscription/createPortalSession.ts deleted file mode 100644 index 9395f19..0000000 --- a/packages/frontend/src/lib/server/subscription/createPortalSession.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getServerURL } from "@/lib/utils"; - -interface CreatePortalParams { - csrfToken: string; - onSuccess?: (url: string) => void; - onError?: (error: string) => void; -} - -export async function createPortalSession({ csrfToken, onSuccess, onError }: CreatePortalParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/create-portal-session`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRF-Token": csrfToken, - }, - credentials: "include", - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to create portal session"); - return; - } - - onSuccess?.(data.url); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/lib/server/subscription/getSubscription.ts b/packages/frontend/src/lib/server/subscription/getSubscription.ts deleted file mode 100644 index e23d3c3..0000000 --- a/packages/frontend/src/lib/server/subscription/getSubscription.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { SubscriptionRecord } from "@sprint/shared"; -import { getServerURL } from "@/lib/utils"; - -interface GetSubscriptionParams { - onSuccess?: (subscription: SubscriptionRecord | null) => void; - onError?: (error: string) => void; -} - -export async function getSubscription({ onSuccess, onError }: GetSubscriptionParams) { - try { - const response = await fetch(`${getServerURL()}/subscription/get`, { - method: "GET", - credentials: "include", - }); - - const data = await response.json(); - - if (!response.ok) { - onError?.(data.error || "Failed to fetch subscription"); - return; - } - - onSuccess?.(data.subscription); - } catch (error) { - onError?.("Network error"); - } -} diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx index 62d146a..b0bf507 100644 --- a/packages/frontend/src/pages/Plans.tsx +++ b/packages/frontend/src/pages/Plans.tsx @@ -1,3 +1,4 @@ +import type { SubscriptionResponse } from "@sprint/shared"; import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { LoginModal } from "@/components/login-modal"; @@ -9,44 +10,37 @@ import { Switch } from "@/components/ui/switch"; import { createCheckoutSession } from "@/lib/server/subscription/createCheckoutSession"; import { createPortalSession } from "@/lib/server/subscription/createPortalSession"; import { getSubscription } from "@/lib/server/subscription/getSubscription"; -import { cn, getCsrfToken } from "@/lib/utils"; - -interface SubscriptionData { - status: string; - currentPeriodEnd: Date | null; - quantity: number; -} +import { cn } from "@/lib/utils"; export default function Plans() { const { user, isLoading } = useSession(); const navigate = useNavigate(); const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual"); const [loginModalOpen, setLoginModalOpen] = useState(false); - const [subscription, setSubscription] = useState(null); - const [loadingSubscription, setLoadingSubscription] = useState(false); + const [subscription, setSubscription] = useState(null); const [processingTier, setProcessingTier] = useState(null); // fetch subscription if user is logged in useEffect(() => { if (user) { - setLoadingSubscription(true); - getSubscription({ - onSuccess: (data) => { - setSubscription(data); - setLoadingSubscription(false); - }, - onError: () => { + getSubscription() + .then((result) => { + const data = result.data as { subscription?: SubscriptionResponse } | null; + if (data?.subscription) { + setSubscription(data.subscription); + } else { + setSubscription(null); + } + }) + .catch(() => { setSubscription(null); - setLoadingSubscription(false); - }, - }); + }); } }, [user]); const hasProSubscription = subscription?.status === "active"; - const csrfToken = getCsrfToken() || ""; - const handleTierAction = (tierName: string) => { + const handleTierAction = async (tierName: string) => { if (!user) { setLoginModalOpen(true); return; @@ -56,28 +50,23 @@ export default function Plans() { if (hasProSubscription) { // open customer portal setProcessingTier(tierName); - createPortalSession({ - csrfToken, - onSuccess: (url) => { - window.location.href = url; - }, - onError: () => { - setProcessingTier(null); - }, - }); + const result = await createPortalSession(); + const portalData = result.data as { url?: string } | null; + if (portalData?.url) { + window.location.href = portalData.url; + } else { + setProcessingTier(null); + } } else { // start checkout setProcessingTier(tierName); - createCheckoutSession({ - billingPeriod, - csrfToken, - onSuccess: (url) => { - window.location.href = url; - }, - onError: () => { - setProcessingTier(null); - }, - }); + const result = await createCheckoutSession({ billingPeriod }); + const checkoutData = result.data as { url?: string } | null; + if (checkoutData?.url) { + window.location.href = checkoutData.url; + } else { + setProcessingTier(null); + } } } // starter tier - just go to issues if not already there @@ -220,6 +209,33 @@ 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. +

+
+
+
diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index e6ac4d0..fbd614d 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -602,3 +602,48 @@ export const SuccessResponseSchema = z.object({ }); export type SuccessResponse = z.infer; + +// subscription schemas + +export const CreateCheckoutSessionRequestSchema = z.object({ + billingPeriod: z.enum(["monthly", "annual"]), +}); + +export type CreateCheckoutSessionRequest = z.infer; + +export const CreateCheckoutSessionResponseSchema = z.object({ + url: z.string(), +}); + +export type CreateCheckoutSessionResponse = z.infer; + +export const CreatePortalSessionResponseSchema = z.object({ + url: z.string(), +}); + +export type CreatePortalSessionResponse = z.infer; + +export const SubscriptionRecordSchema = z.object({ + id: z.number(), + userId: z.number(), + stripeCustomerId: z.string().nullable(), + stripeSubscriptionId: z.string().nullable(), + stripeSubscriptionItemId: z.string().nullable(), + stripePriceId: z.string().nullable(), + status: z.string(), + currentPeriodStart: z.string().nullable().optional(), + currentPeriodEnd: z.string().nullable().optional(), + cancelAtPeriodEnd: z.boolean(), + trialEnd: z.string().nullable().optional(), + quantity: z.number(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export type SubscriptionRecord = z.infer; + +export const GetSubscriptionResponseSchema = z.object({ + subscription: SubscriptionRecordSchema.nullable(), +}); + +export type GetSubscriptionResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 139f95e..3141c29 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -3,6 +3,10 @@ import { z } from "zod"; import { ApiErrorSchema, AuthResponseSchema, + CreateCheckoutSessionRequestSchema, + CreateCheckoutSessionResponseSchema, + CreatePortalSessionResponseSchema, + GetSubscriptionResponseSchema, IssueByIdQuerySchema, IssueCommentCreateRequestSchema, IssueCommentDeleteRequestSchema, @@ -600,6 +604,38 @@ export const apiContract = c.router({ 200: z.array(timerListItemResponseSchema), }, }, + + subscriptionCreateCheckoutSession: { + method: "POST", + path: "/subscription/create-checkout-session", + body: CreateCheckoutSessionRequestSchema, + responses: { + 200: CreateCheckoutSessionResponseSchema, + 400: ApiErrorSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + subscriptionCreatePortalSession: { + method: "POST", + path: "/subscription/create-portal-session", + body: emptyBodySchema, + responses: { + 200: CreatePortalSessionResponseSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + subscriptionGet: { + method: "GET", + path: "/subscription/get", + responses: { + 200: GetSubscriptionResponseSchema, + 500: ApiErrorSchema, + }, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 587882e..108dc0a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,10 @@ export type { ApiError, AuthResponse, + CreateCheckoutSessionRequest, + CreateCheckoutSessionResponse, + CreatePortalSessionResponse, + GetSubscriptionResponse, IssueByIdQuery, IssueCommentCreateRequest, IssueCommentDeleteRequest, @@ -46,6 +50,7 @@ export type { SprintsByProjectQuery, SprintUpdateRequest, StatusCountResponse, + SubscriptionRecord as SubscriptionResponse, SuccessResponse, TimerEndRequest, TimerGetQuery, @@ -62,6 +67,10 @@ export type { export { ApiErrorSchema, AuthResponseSchema, + CreateCheckoutSessionRequestSchema, + CreateCheckoutSessionResponseSchema, + CreatePortalSessionResponseSchema, + GetSubscriptionResponseSchema, IssueByIdQuerySchema, IssueCommentCreateRequestSchema, IssueCommentDeleteRequestSchema, @@ -110,6 +119,7 @@ export { SprintsByProjectQuerySchema, SprintUpdateRequestSchema, StatusCountResponseSchema, + SubscriptionRecordSchema as SubscriptionRecordApiSchema, SuccessResponseSchema, TimerEndRequestSchema, TimerGetQuerySchema, @@ -165,7 +175,7 @@ export type { SprintInsert, SprintRecord, SubscriptionInsert, - SubscriptionRecord, + SubscriptionRecord as SubscriptionRecordType, TimedSessionInsert, TimedSessionRecord, TimerState, From 65964d64f639f16e28e9d37ec45c8963978fcc0a Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 20:16:51 +0000 Subject: [PATCH 16/25] use hooks on plans page --- packages/frontend/src/main.tsx | 9 ----- packages/frontend/src/pages/Plans.tsx | 55 +++++++++++---------------- 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index e0d83d3..0245714 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -13,7 +13,6 @@ import Issues from "@/pages/Issues"; import Landing from "@/pages/Landing"; import NotFound from "@/pages/NotFound"; import Plans from "@/pages/Plans"; -import StripeTest from "@/pages/StripeTest"; import Test from "@/pages/Test"; import Timeline from "@/pages/Timeline"; @@ -54,14 +53,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> - - - - } - /> ("annual"); const [loginModalOpen, setLoginModalOpen] = useState(false); - const [subscription, setSubscription] = useState(null); const [processingTier, setProcessingTier] = useState(null); - // fetch subscription if user is logged in - useEffect(() => { - if (user) { - getSubscription() - .then((result) => { - const data = result.data as { subscription?: SubscriptionResponse } | null; - if (data?.subscription) { - setSubscription(data.subscription); - } else { - setSubscription(null); - } - }) - .catch(() => { - setSubscription(null); - }); - } - }, [user]); + const { data: subscriptionData } = useSubscription(); + const createCheckoutSession = useCreateCheckoutSession(); + const createPortalSession = useCreatePortalSession(); + const subscription = subscriptionData?.subscription ?? null; const hasProSubscription = subscription?.status === "active"; const handleTierAction = async (tierName: string) => { @@ -50,21 +33,27 @@ export default function Plans() { if (hasProSubscription) { // open customer portal setProcessingTier(tierName); - const result = await createPortalSession(); - const portalData = result.data as { url?: string } | null; - if (portalData?.url) { - window.location.href = portalData.url; - } else { + try { + const result = await createPortalSession.mutateAsync(); + if (result.url) { + window.location.href = result.url; + } else { + setProcessingTier(null); + } + } catch { setProcessingTier(null); } } else { // start checkout setProcessingTier(tierName); - const result = await createCheckoutSession({ billingPeriod }); - const checkoutData = result.data as { url?: string } | null; - if (checkoutData?.url) { - window.location.href = checkoutData.url; - } else { + try { + const result = await createCheckoutSession.mutateAsync({ billingPeriod }); + if (result.url) { + window.location.href = result.url; + } else { + setProcessingTier(null); + } + } catch { setProcessingTier(null); } } From d4cc50f289d00826fdeda8c99b829e11fc92b9ce Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 21:13:15 +0000 Subject: [PATCH 17/25] fix: proper cancellation handling --- bun.lock | 5 + packages/backend/src/index.ts | 1 + packages/backend/src/routes/index.ts | 2 + .../backend/src/routes/subscription/cancel.ts | 68 +++++++++ .../src/routes/subscription/webhook.ts | 27 ++-- packages/frontend/package.json | 1 + .../src/components/ui/alert-dialog.tsx | 131 ++++++++++++++++++ .../src/lib/query/hooks/subscriptions.ts | 20 ++- packages/frontend/src/pages/Plans.tsx | 104 ++++++++++++-- packages/shared/src/api-schemas.ts | 6 + packages/shared/src/contract.ts | 12 ++ packages/shared/src/index.ts | 2 + 12 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 packages/backend/src/routes/subscription/cancel.ts create mode 100644 packages/frontend/src/components/ui/alert-dialog.tsx diff --git a/bun.lock b/bun.lock index 6224f0d..3657e1f 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@iconify/react": "^6.0.2", "@nsmr/pixelart-react": "^2.0.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -279,6 +280,8 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], @@ -837,6 +840,8 @@ "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 82877cc..1d37a40 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -108,6 +108,7 @@ const main = async () => { "/subscription/create-portal-session": withGlobalAuthed( withAuth(withCSRF(routes.subscriptionCreatePortalSession)), ), + "/subscription/cancel": withGlobalAuthed(withAuth(withCSRF(routes.subscriptionCancel))), "/subscription/get": withGlobalAuthed(withAuth(routes.subscriptionGet)), "/subscription/webhook": withGlobal(routes.subscriptionWebhook), }, diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index d52e2b9..8ed144b 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -38,6 +38,7 @@ import sprintCreate from "./sprint/create"; import sprintDelete from "./sprint/delete"; import sprintUpdate from "./sprint/update"; import sprintsByProject from "./sprints/by-project"; +import subscriptionCancel from "./subscription/cancel"; import subscriptionCreateCheckoutSession from "./subscription/create-checkout-session"; import subscriptionCreatePortalSession from "./subscription/create-portal-session"; import subscriptionGet from "./subscription/get"; @@ -113,6 +114,7 @@ export const routes = { subscriptionCreateCheckoutSession, subscriptionCreatePortalSession, + subscriptionCancel, subscriptionGet, subscriptionWebhook, }; diff --git a/packages/backend/src/routes/subscription/cancel.ts b/packages/backend/src/routes/subscription/cancel.ts new file mode 100644 index 0000000..74806f0 --- /dev/null +++ b/packages/backend/src/routes/subscription/cancel.ts @@ -0,0 +1,68 @@ +import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware"; +import { getSubscriptionByUserId, updateSubscription } from "../../db/queries/subscriptions"; +import { stripe } from "../../stripe/client"; +import { errorResponse } from "../../validation"; + +async function handler(req: AuthedRequest) { + if (req.method !== "POST") { + return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405); + } + + try { + const { userId } = req; + const subscription = await getSubscriptionByUserId(userId); + if (!subscription?.stripeSubscriptionId) { + return errorResponse("no active subscription found", "NOT_FOUND", 404); + } + + const stripeCurrent = (await stripe.subscriptions.retrieve( + subscription.stripeSubscriptionId, + )) as unknown as { + status: string; + cancel_at_period_end: boolean | null; + current_period_end: number | null; + }; + + const currentPeriodEnd = stripeCurrent.current_period_end + ? new Date(stripeCurrent.current_period_end * 1000) + : undefined; + + if (stripeCurrent.status === "canceled" || stripeCurrent.cancel_at_period_end) { + const updated = await updateSubscription(subscription.id, { + status: stripeCurrent.status, + cancelAtPeriodEnd: stripeCurrent.cancel_at_period_end ?? subscription.cancelAtPeriodEnd, + ...(currentPeriodEnd && { currentPeriodEnd }), + }); + return new Response(JSON.stringify({ subscription: updated }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const stripeSubscription = (await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, + })) as unknown as { + status: string; + cancel_at_period_end: boolean | null; + current_period_end: number | null; + }; + + const updated = await updateSubscription(subscription.id, { + status: stripeSubscription.status, + cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? true, + currentPeriodEnd: stripeSubscription.current_period_end + ? new Date(stripeSubscription.current_period_end * 1000) + : undefined, + }); + + return new Response(JSON.stringify({ subscription: updated }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("subscription cancel error:", error); + return errorResponse("failed to cancel subscription", "CANCEL_ERROR", 500); + } +} + +export default withCors(withAuth(withCSRF(handler))); diff --git a/packages/backend/src/routes/subscription/webhook.ts b/packages/backend/src/routes/subscription/webhook.ts index 4040ea5..92d4b8e 100644 --- a/packages/backend/src/routes/subscription/webhook.ts +++ b/packages/backend/src/routes/subscription/webhook.ts @@ -11,6 +11,15 @@ import { stripe } from "../../stripe/client"; const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET"); +function toStripeDate(seconds: number | null | undefined, field: string) { + if (seconds === null || seconds === undefined) return undefined; + if (!Number.isFinite(seconds)) { + console.warn(`invalid ${field} timestamp:`, seconds); + return undefined; + } + return new Date(seconds * 1000); +} + function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -71,8 +80,8 @@ export default async function webhook(req: BunRequest) { // stripe types use snake_case for these fields const sub = stripeSubscription as unknown as { - current_period_start: number; - current_period_end: number; + current_period_start: number | null; + current_period_end: number | null; trial_end: number | null; }; @@ -84,9 +93,9 @@ export default async function webhook(req: BunRequest) { stripePriceId: session.metadata?.priceId || "", status: stripeSubscription.status, quantity: parseInt(session.metadata?.quantity || "1", 10), - currentPeriodStart: new Date(sub.current_period_start * 1000), - currentPeriodEnd: new Date(sub.current_period_end * 1000), - trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : undefined, + currentPeriodStart: toStripeDate(sub.current_period_start, "current_period_start"), + currentPeriodEnd: toStripeDate(sub.current_period_end, "current_period_end"), + trialEnd: toStripeDate(sub.trial_end, "trial_end"), }); await updateUser(userId, { plan: "pro" }); @@ -117,12 +126,8 @@ export default async function webhook(req: BunRequest) { current_period_start: number | null; current_period_end: number | null; }; - const currentPeriodStart = sub.current_period_start - ? new Date(sub.current_period_start * 1000) - : undefined; - const currentPeriodEnd = sub.current_period_end - ? new Date(sub.current_period_end * 1000) - : undefined; + const currentPeriodStart = toStripeDate(sub.current_period_start, "current_period_start"); + const currentPeriodEnd = toStripeDate(sub.current_period_end, "current_period_end"); await updateSubscription(localSub.id, { status: subscription.status, diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 711e00c..71458e7 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -14,6 +14,7 @@ "@ts-rest/core": "^3.52.1", "@nsmr/pixelart-react": "^2.0.0", "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/packages/frontend/src/components/ui/alert-dialog.tsx b/packages/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..d7281f9 --- /dev/null +++ b/packages/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,131 @@ +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import type * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +type AlertDialogActionProps = React.ComponentProps & + Omit, "asChild">; + +function AlertDialogAction({ className, ...props }: AlertDialogActionProps) { + return ( + +
+ {user && isProUser && ( +
+
+
+

Cancel subscription

+

+ {isCancellationScheduled || isCanceled + ? `Cancelled, benefits end on ${cancellationEndDate ?? "your billing end date"}.` + : "Canceling will keep access until the end of your billing period."} +

+
+ { + setCancelDialogOpen(open); + if (!open) setCancelError(null); + }} + > + + + + + + Cancel subscription? + + You will keep Pro access until the end of your current billing period. + + + + Keep subscription + + {cancelSubscription.isPending ? "Canceling..." : "Confirm cancel"} + + + {cancelError &&

{cancelError}

} +
+
+
+
+ )} + {/* trust signals */}
diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index fbd614d..e505e11 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -647,3 +647,9 @@ export const GetSubscriptionResponseSchema = z.object({ }); export type GetSubscriptionResponse = z.infer; + +export const CancelSubscriptionResponseSchema = z.object({ + subscription: SubscriptionRecordSchema, +}); + +export type CancelSubscriptionResponse = z.infer; diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 3141c29..90e8079 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ApiErrorSchema, AuthResponseSchema, + CancelSubscriptionResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, @@ -628,6 +629,17 @@ export const apiContract = c.router({ }, headers: csrfHeaderSchema, }, + subscriptionCancel: { + method: "POST", + path: "/subscription/cancel", + body: emptyBodySchema, + responses: { + 200: CancelSubscriptionResponseSchema, + 404: ApiErrorSchema, + 500: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, subscriptionGet: { method: "GET", path: "/subscription/get", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 108dc0a..fc80037 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ export type { ApiError, AuthResponse, + CancelSubscriptionResponse, CreateCheckoutSessionRequest, CreateCheckoutSessionResponse, CreatePortalSessionResponse, @@ -67,6 +68,7 @@ export type { export { ApiErrorSchema, AuthResponseSchema, + CancelSubscriptionResponseSchema, CreateCheckoutSessionRequestSchema, CreateCheckoutSessionResponseSchema, CreatePortalSessionResponseSchema, From c0e06ac8ba6997c1e8d0647feb00a1d391b25d1d Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 21:34:26 +0000 Subject: [PATCH 18/25] User.email and implementation --- .../drizzle/0027_volatile_otto_octavius.sql | 4 + .../backend/drizzle/meta/0027_snapshot.json | 1159 +++++++++++++++++ packages/backend/drizzle/meta/_journal.json | 7 + packages/backend/scripts/db-seed.ts | 16 +- packages/backend/src/db/queries/users.ts | 11 +- packages/backend/src/routes/auth/register.ts | 14 +- .../subscription/create-checkout-session.ts | 6 +- .../frontend/src/components/login-form.tsx | 16 +- packages/shared/src/api-schemas.ts | 2 + packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/schema.ts | 2 + 12 files changed, 1220 insertions(+), 19 deletions(-) create mode 100644 packages/backend/drizzle/0027_volatile_otto_octavius.sql create mode 100644 packages/backend/drizzle/meta/0027_snapshot.json diff --git a/packages/backend/drizzle/0027_volatile_otto_octavius.sql b/packages/backend/drizzle/0027_volatile_otto_octavius.sql new file mode 100644 index 0000000..aaa2d87 --- /dev/null +++ b/packages/backend/drizzle/0027_volatile_otto_octavius.sql @@ -0,0 +1,4 @@ +ALTER TABLE "User" ADD COLUMN "email" varchar(255);--> statement-breakpoint +UPDATE "User" SET "email" = 'user_' || id || '@placeholder.local' WHERE "email" IS NULL;--> statement-breakpoint +ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "User" ADD CONSTRAINT "User_email_unique" UNIQUE("email"); \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0027_snapshot.json b/packages/backend/drizzle/meta/0027_snapshot.json new file mode 100644 index 0000000..ba821f3 --- /dev/null +++ b/packages/backend/drizzle/meta/0027_snapshot.json @@ -0,0 +1,1159 @@ +{ + "id": "b826ec09-e4ac-49b1-9975-b36f5be69b0b", + "prevId": "9104baeb-85d7-4fdb-87cc-9d5ac6b85ec8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Issue": { + "name": "Issue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Issue_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'Task'" + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'TO DO'" + }, + "title": { + "name": "title", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sprintId": { + "name": "sprintId", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "unique_project_issue_number": { + "name": "unique_project_issue_number", + "columns": [ + { + "expression": "projectId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "Issue_projectId_Project_id_fk": { + "name": "Issue_projectId_Project_id_fk", + "tableFrom": "Issue", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_creatorId_User_id_fk": { + "name": "Issue_creatorId_User_id_fk", + "tableFrom": "Issue", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Issue_sprintId_Sprint_id_fk": { + "name": "Issue_sprintId_Sprint_id_fk", + "tableFrom": "Issue", + "tableTo": "Sprint", + "columnsFrom": [ + "sprintId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueAssignee": { + "name": "IssueAssignee", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueAssignee_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "assignedAt": { + "name": "assignedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_issue_user": { + "name": "unique_issue_user", + "columns": [ + { + "expression": "issueId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "IssueAssignee_issueId_Issue_id_fk": { + "name": "IssueAssignee_issueId_Issue_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueAssignee_userId_User_id_fk": { + "name": "IssueAssignee_userId_User_id_fk", + "tableFrom": "IssueAssignee", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.IssueComment": { + "name": "IssueComment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "IssueComment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "IssueComment_issueId_Issue_id_fk": { + "name": "IssueComment_issueId_Issue_id_fk", + "tableFrom": "IssueComment", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "IssueComment_userId_User_id_fk": { + "name": "IssueComment_userId_User_id_fk", + "tableFrom": "IssueComment", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Organisation": { + "name": "Organisation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Organisation_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "iconURL": { + "name": "iconURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "statuses": { + "name": "statuses", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json" + }, + "issueTypes": { + "name": "issueTypes", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json" + }, + "features": { + "name": "features", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "Organisation_slug_unique": { + "name": "Organisation_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.OrganisationMember": { + "name": "OrganisationMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "OrganisationMember_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "OrganisationMember_organisationId_Organisation_id_fk": { + "name": "OrganisationMember_organisationId_Organisation_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "OrganisationMember_userId_User_id_fk": { + "name": "OrganisationMember_userId_User_id_fk", + "tableFrom": "OrganisationMember", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Payment": { + "name": "Payment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Payment_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripePaymentIntentId": { + "name": "stripePaymentIntentId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'gbp'" + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Payment_subscriptionId_Subscription_id_fk": { + "name": "Payment_subscriptionId_Subscription_id_fk", + "tableFrom": "Payment", + "tableTo": "Subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Project": { + "name": "Project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Project_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "key": { + "name": "key", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "organisationId": { + "name": "organisationId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Project_organisationId_Organisation_id_fk": { + "name": "Project_organisationId_Organisation_id_fk", + "tableFrom": "Project", + "tableTo": "Organisation", + "columnsFrom": [ + "organisationId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Project_creatorId_User_id_fk": { + "name": "Project_creatorId_User_id_fk", + "tableFrom": "Project", + "tableTo": "User", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Session": { + "name": "Session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Session_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "csrfToken": { + "name": "csrfToken", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Session_userId_User_id_fk": { + "name": "Session_userId_User_id_fk", + "tableFrom": "Session", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Sprint": { + "name": "Sprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Sprint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "projectId": { + "name": "projectId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#a1a1a1'" + }, + "startDate": { + "name": "startDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "endDate": { + "name": "endDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Sprint_projectId_Project_id_fk": { + "name": "Sprint_projectId_Project_id_fk", + "tableFrom": "Sprint", + "tableTo": "Project", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.Subscription": { + "name": "Subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "Subscription_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionId": { + "name": "stripeSubscriptionId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripeSubscriptionItemId": { + "name": "stripeSubscriptionItemId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripePriceId": { + "name": "stripePriceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "Subscription_userId_User_id_fk": { + "name": "Subscription_userId_User_id_fk", + "tableFrom": "Subscription", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.TimedSession": { + "name": "TimedSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "TimedSession_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "userId": { + "name": "userId", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issueId": { + "name": "issueId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "timestamps": { + "name": "timestamps", + "type": "timestamp[]", + "primaryKey": false, + "notNull": true + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "TimedSession_userId_User_id_fk": { + "name": "TimedSession_userId_User_id_fk", + "tableFrom": "TimedSession", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "TimedSession_issueId_Issue_id_fk": { + "name": "TimedSession_issueId_Issue_id_fk", + "tableFrom": "TimedSession", + "tableTo": "Issue", + "columnsFrom": [ + "issueId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "User_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "avatarURL": { + "name": "avatarURL", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "iconPreference": { + "name": "iconPreference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'pixel'" + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "User_username_unique": { + "name": "User_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "User_email_unique": { + "name": "User_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index baed3b1..7ee5a8f 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1769615487574, "tag": "0026_stale_shocker", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1769635016079, + "tag": "0027_volatile_otto_octavius", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/scripts/db-seed.ts b/packages/backend/scripts/db-seed.ts index 56ad15e..ccb0ee2 100644 --- a/packages/backend/scripts/db-seed.ts +++ b/packages/backend/scripts/db-seed.ts @@ -97,15 +97,15 @@ const issueComments = [ const passwordHash = await hashPassword("a"); const users = [ - { name: "user 1", username: "u1", passwordHash, avatarURL: null }, - { name: "user 2", username: "u2", passwordHash, avatarURL: null }, + { name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null }, + { name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null }, // anything past here is just to have more users to assign issues to - { name: "user 3", username: "u3", passwordHash, avatarURL: null }, - { name: "user 4", username: "u4", passwordHash, avatarURL: null }, - { name: "user 5", username: "u5", passwordHash, avatarURL: null }, - { name: "user 6", username: "u6", passwordHash, avatarURL: null }, - { name: "user 7", username: "u7", passwordHash, avatarURL: null }, - { name: "user 8", username: "u8", passwordHash, avatarURL: null }, + { name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null }, + { name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null }, + { name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null }, + { name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null }, + { name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null }, + { name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null }, ]; async function seed() { diff --git a/packages/backend/src/db/queries/users.ts b/packages/backend/src/db/queries/users.ts index 83f1fc6..ca9a567 100644 --- a/packages/backend/src/db/queries/users.ts +++ b/packages/backend/src/db/queries/users.ts @@ -5,10 +5,14 @@ import { db } from "../client"; export async function createUser( name: string, username: string, + email: string, passwordHash: string, avatarURL?: string | null, ) { - const [user] = await db.insert(User).values({ name, username, passwordHash, avatarURL }).returning(); + const [user] = await db + .insert(User) + .values({ name, username, email, passwordHash, avatarURL }) + .returning(); return user; } @@ -22,6 +26,11 @@ export async function getUserByUsername(username: string) { return user; } +export async function getUserByEmail(email: string) { + const [user] = await db.select().from(User).where(eq(User.email, email)); + return user; +} + export async function updateById( id: number, updates: { diff --git a/packages/backend/src/routes/auth/register.ts b/packages/backend/src/routes/auth/register.ts index 6ca2ab0..37d8b3b 100644 --- a/packages/backend/src/routes/auth/register.ts +++ b/packages/backend/src/routes/auth/register.ts @@ -2,6 +2,7 @@ import { RegisterRequestSchema } from "@sprint/shared"; import type { BunRequest } from "bun"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; import { createSession, createUser, getUserByUsername } from "../../db/queries"; +import { getUserByEmail } from "../../db/queries/users"; import { errorResponse, parseJsonBody } from "../../validation"; export default async function register(req: BunRequest) { @@ -12,15 +13,20 @@ export default async function register(req: BunRequest) { const parsed = await parseJsonBody(req, RegisterRequestSchema); if ("error" in parsed) return parsed.error; - const { name, username, password, avatarURL } = parsed.data; + const { name, username, email, password, avatarURL } = parsed.data; - const existing = await getUserByUsername(username); - if (existing) { + const existingUsername = await getUserByUsername(username); + if (existingUsername) { return errorResponse("username already taken", "USERNAME_TAKEN", 400); } + const existingEmail = await getUserByEmail(email); + if (existingEmail) { + return errorResponse("email already registered", "EMAIL_TAKEN", 400); + } + const passwordHash = await hashPassword(password); - const user = await createUser(name, username, passwordHash, avatarURL); + const user = await createUser(name, username, email, passwordHash, avatarURL); if (!user) { return errorResponse("failed to create user", "USER_CREATE_ERROR", 500); } diff --git a/packages/backend/src/routes/subscription/create-checkout-session.ts b/packages/backend/src/routes/subscription/create-checkout-session.ts index b0e72d0..61f1aa5 100644 --- a/packages/backend/src/routes/subscription/create-checkout-session.ts +++ b/packages/backend/src/routes/subscription/create-checkout-session.ts @@ -39,10 +39,8 @@ async function handler(req: AuthedRequest) { const quantity = Math.max(1, totalMembers - 5); const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY; - // build customer data - use username as email if no email field exists - const customerEmail = user.username.includes("@") - ? user.username - : `${user.username}@localhost.local`; + // use the user's email from the database + const customerEmail = user.email; const session = await stripe.checkout.sessions.create({ customer_email: customerEmail, diff --git a/packages/frontend/src/components/login-form.tsx b/packages/frontend/src/components/login-form.tsx index 9093341..68187a8 100644 --- a/packages/frontend/src/components/login-form.tsx +++ b/packages/frontend/src/components/login-form.tsx @@ -1,6 +1,6 @@ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ -import { USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; +import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import Avatar from "@/components/avatar"; @@ -36,6 +36,7 @@ export default function LogInForm({ const [name, setName] = useState(""); const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [avatarURL, setAvatarUrl] = useState(null); const [error, setError] = useState(""); @@ -75,7 +76,7 @@ export default function LogInForm({ }; const register = () => { - if (name.trim() === "" || username.trim() === "" || password.trim() === "") { + if (name.trim() === "" || username.trim() === "" || email.trim() === "" || password.trim() === "") { return; } @@ -85,6 +86,7 @@ export default function LogInForm({ body: JSON.stringify({ name, username, + email, password, avatarURL, }), @@ -129,6 +131,7 @@ export default function LogInForm({ setError(""); setSubmitAttempted(false); setAvatarUrl(null); + setEmail(""); requestAnimationFrame(() => focusFirstInput()); }; @@ -249,6 +252,15 @@ export default function LogInForm({ spellcheck={false} maxLength={USER_NAME_MAX_LENGTH} /> + setEmail(e.target.value)} + validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)} + submitAttempted={submitAttempted} + spellcheck={false} + maxLength={USER_EMAIL_MAX_LENGTH} + /> )} (), From 7f3cb7c8900bf0d5963bb743a126b22deff146d7 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 22:12:32 +0000 Subject: [PATCH 19/25] Free/Pro plan limitations --- packages/backend/src/db/queries/index.ts | 8 ++ packages/backend/src/db/queries/issues.ts | 19 +++ .../backend/src/db/queries/organisations.ts | 10 +- packages/backend/src/db/queries/projects.ts | 10 +- packages/backend/src/routes/issue/create.ts | 22 +++- .../src/routes/organisation/add-member.ts | 15 +++ .../backend/src/routes/organisation/create.ts | 21 +++- packages/backend/src/routes/project/create.ts | 21 +++- .../src/components/free-tier-limit.tsx | 93 ++++++++++++++ .../frontend/src/components/issue-form.tsx | 39 +++++- .../src/components/organisation-select.tsx | 33 ++++- .../frontend/src/components/organisations.tsx | 117 +++++++++++++++--- .../src/components/project-select.tsx | 36 +++++- packages/frontend/src/pages/Landing.tsx | 5 - packages/frontend/src/pages/Plans.tsx | 31 +---- 15 files changed, 420 insertions(+), 60 deletions(-) create mode 100644 packages/frontend/src/components/free-tier-limit.tsx 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. -

-
-
-
From 14520618d1adc9a55262281ec312aa7c97fb98dd Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 23:36:03 +0000 Subject: [PATCH 20/25] 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} + /> + )} +
+ + +
+ + + + ); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts index b998c4e..7c0564f 100644 --- a/packages/frontend/src/lib/query/hooks/index.ts +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -7,3 +7,4 @@ export * from "@/lib/query/hooks/sprints"; export * from "@/lib/query/hooks/subscriptions"; export * from "@/lib/query/hooks/timers"; export * from "@/lib/query/hooks/users"; +export * from "@/lib/query/hooks/verification"; diff --git a/packages/frontend/src/lib/query/hooks/verification.ts b/packages/frontend/src/lib/query/hooks/verification.ts new file mode 100644 index 0000000..c0ebdfe --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/verification.ts @@ -0,0 +1,22 @@ +import { useMutation } from "@tanstack/react-query"; +import { apiClient } from "@/lib/server"; + +export function useVerifyEmail() { + return useMutation({ + mutationKey: ["verification", "verify"], + mutationFn: async ({ code }) => { + const { error } = await apiClient.authVerifyEmail({ body: { code } }); + if (error) throw new Error(error); + }, + }); +} + +export function useResendVerification() { + return useMutation({ + mutationKey: ["verification", "resend"], + mutationFn: async () => { + const { error } = await apiClient.authResendVerification({ body: {} }); + if (error) throw new Error(error); + }, + }); +} diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index e02ecf4..54add57 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -60,12 +60,21 @@ export const AuthResponseSchema = z.object({ username: z.string(), avatarURL: z.string().nullable(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]), + emailVerified: z.boolean(), }), csrfToken: z.string(), }); export type AuthResponse = z.infer; +// email verification schemas + +export const VerifyEmailRequestSchema = z.object({ + code: z.string().length(6, "Verification code must be 6 digits"), +}); + +export type VerifyEmailRequest = z.infer; + // issue schemas export const IssueCreateRequestSchema = z.object({ diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index ba2261a..2580883 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -649,6 +649,30 @@ export const apiContract = c.router({ 500: ApiErrorSchema, }, }, + + authVerifyEmail: { + method: "POST", + path: "/auth/verify-email", + body: z.object({ code: z.string() }), + responses: { + 200: SuccessResponseSchema, + 400: ApiErrorSchema, + 401: ApiErrorSchema, + 404: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, + authResendVerification: { + method: "POST", + path: "/auth/resend-verification", + body: emptyBodySchema, + responses: { + 200: SuccessResponseSchema, + 400: ApiErrorSchema, + 401: ApiErrorSchema, + }, + headers: csrfHeaderSchema, + }, }); export type ApiContract = typeof apiContract; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5cfa1c3..ef65a17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -63,6 +63,7 @@ export type { UserByUsernameQuery, UserResponse, UserUpdateRequest, + VerifyEmailRequest, } from "./api-schemas"; // API schemas export { @@ -133,6 +134,7 @@ export { UserByUsernameQuerySchema, UserResponseSchema, UserUpdateRequestSchema, + VerifyEmailRequestSchema, } from "./api-schemas"; export { ISSUE_COMMENT_MAX_LENGTH, @@ -153,6 +155,10 @@ export { export type { ApiContract } from "./contract"; export { apiContract } from "./contract"; export type { + EmailJobInsert, + EmailJobRecord, + EmailVerificationInsert, + EmailVerificationRecord, IconStyle, IssueAssigneeInsert, IssueAssigneeRecord, @@ -191,6 +197,12 @@ export { DEFAULT_SPRINT_COLOUR, DEFAULT_STATUS_COLOUR, DEFAULT_STATUS_COLOURS, + EmailJob, + EmailJobInsertSchema, + EmailJobSelectSchema, + EmailVerification, + EmailVerificationInsertSchema, + EmailVerificationSelectSchema, Issue, IssueAssignee, IssueAssigneeInsertSchema, diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index b2166c5..c286cec 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -1,4 +1,4 @@ -import { boolean, integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; +import { boolean, integer, json, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import type { z } from "zod"; import { @@ -62,6 +62,8 @@ export const User = pgTable("User", { avatarURL: varchar({ length: 512 }), iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type(), plan: varchar({ length: 32 }).notNull().default("free"), + emailVerified: boolean().notNull().default(false), + emailVerifiedAt: timestamp({ withTimezone: false }), createdAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); @@ -195,7 +197,6 @@ export const IssueComment = pgTable("IssueComment", { updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Zod schemas export const UserSelectSchema = createSelectSchema(User); export const UserInsertSchema = createInsertSchema(User); @@ -226,7 +227,6 @@ export const SessionInsertSchema = createInsertSchema(Session); export const TimedSessionSelectSchema = createSelectSchema(TimedSession); export const TimedSessionInsertSchema = createInsertSchema(TimedSession); -// Types export type UserRecord = z.infer; export type UserInsert = z.infer; @@ -260,8 +260,6 @@ export type SessionInsert = z.infer; export type TimedSessionRecord = z.infer; export type TimedSessionInsert = z.infer; -// Responses - export type IssueResponse = { Issue: IssueRecord; Creator: UserRecord; @@ -299,7 +297,6 @@ export type TimerState = { endedAt: string | null; } | null; -// Subscription table - tracks user subscriptions export const Subscription = pgTable("Subscription", { id: integer().primaryKey().generatedAlwaysAsIdentity(), userId: integer() @@ -319,7 +316,6 @@ export const Subscription = pgTable("Subscription", { updatedAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Payment history table export const Payment = pgTable("Payment", { id: integer().primaryKey().generatedAlwaysAsIdentity(), subscriptionId: integer() @@ -332,16 +328,53 @@ export const Payment = pgTable("Payment", { createdAt: timestamp({ withTimezone: false }).defaultNow(), }); -// Zod schemas for Subscription and Payment export const SubscriptionSelectSchema = createSelectSchema(Subscription); export const SubscriptionInsertSchema = createInsertSchema(Subscription); export const PaymentSelectSchema = createSelectSchema(Payment); export const PaymentInsertSchema = createInsertSchema(Payment); -// Types for Subscription and Payment export type SubscriptionRecord = z.infer; export type SubscriptionInsert = z.infer; export type PaymentRecord = z.infer; export type PaymentInsert = z.infer; + +export const EmailVerification = pgTable("EmailVerification", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer() + .notNull() + .references(() => User.id, { onDelete: "cascade" }), + code: varchar({ length: 6 }).notNull(), + attempts: integer().notNull().default(0), + maxAttempts: integer().notNull().default(5), + expiresAt: timestamp({ withTimezone: false }).notNull(), + verifiedAt: timestamp({ withTimezone: false }), + createdAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +export const EmailVerificationSelectSchema = createSelectSchema(EmailVerification); +export const EmailVerificationInsertSchema = createInsertSchema(EmailVerification); + +export type EmailVerificationRecord = z.infer; +export type EmailVerificationInsert = z.infer; + +export const EmailJob = pgTable("EmailJob", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + userId: integer() + .notNull() + .references(() => User.id, { onDelete: "cascade" }), + type: varchar({ length: 64 }).notNull(), + scheduledFor: timestamp({ withTimezone: false }).notNull(), + sentAt: timestamp({ withTimezone: false }), + failedAt: timestamp({ withTimezone: false }), + errorMessage: text(), + metadata: json("metadata").$type>(), + createdAt: timestamp({ withTimezone: false }).defaultNow(), +}); + +export const EmailJobSelectSchema = createSelectSchema(EmailJob); +export const EmailJobInsertSchema = createInsertSchema(EmailJob); + +export type EmailJobRecord = z.infer; +export type EmailJobInsert = z.infer; From 3d1231c0e66c9ae59b503b6190f01dc2d95a1c62 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 01:00:37 +0000 Subject: [PATCH 22/25] fix: otp boxes too far apart --- packages/frontend/src/components/verification-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/components/verification-modal.tsx b/packages/frontend/src/components/verification-modal.tsx index 6000c07..3e18e31 100644 --- a/packages/frontend/src/components/verification-modal.tsx +++ b/packages/frontend/src/components/verification-modal.tsx @@ -68,7 +68,7 @@ export function VerificationModal({ open, onOpenChange }: VerificationModalProps autoFocus className="gap-2" > - + From b74d86ec781cc509a38f34277d3db86afe62c2c1 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 10:12:02 +0000 Subject: [PATCH 23/25] "Your sprint verification code is: " --- packages/backend/src/emails/templates/VerificationCode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/emails/templates/VerificationCode.tsx b/packages/backend/src/emails/templates/VerificationCode.tsx index 47959ad..0a1e2eb 100644 --- a/packages/backend/src/emails/templates/VerificationCode.tsx +++ b/packages/backend/src/emails/templates/VerificationCode.tsx @@ -1,3 +1,3 @@ export function VerificationCode({ code }: { code: string }) { - return {code}; + return Your sprint verification code is: {code}; } From 37966204e4ee5e7a1bb1550d7328e947a003a40c Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 10:54:14 +0000 Subject: [PATCH 24/25] boring stuff page --- packages/frontend/src/main.tsx | 2 + packages/frontend/src/pages/BoringStuff.tsx | 122 ++++++++++++++++++++ packages/frontend/src/pages/Landing.tsx | 36 +++--- packages/frontend/src/pages/Plans.tsx | 9 ++ 4 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 packages/frontend/src/pages/BoringStuff.tsx diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 0245714..48e3cac 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { SelectionProvider } from "@/components/selection-provider"; import { RequireAuth, SessionProvider } from "@/components/session-provider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; +import BoringStuff from "@/pages/BoringStuff"; import Font from "@/pages/Font"; import Issues from "@/pages/Issues"; import Landing from "@/pages/Landing"; @@ -27,6 +28,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {/* public routes */} } /> } /> + } /> {/* authed routes */} +
+
+
+ Sprint + Sprint +
+ +
+
+ +
+
+
+

The Boring Stuff

+

Let's keep it short.

+
+ +
+

Privacy Policy

+
+

+ What we store: We store your email, name, and any + data you create (issues, projects, time tracking). +

+

+ How we use it: Only your email is used for + subscription alerts and newsletters (you can unsubscribe). +

+

+ Where it's stored: Data is stored on secure + servers. +

+

+ {/* Your rights: You can export or delete your data + anytime. Just email us at privacy@sprintpm.org. */} +

+

+ Cookies: We use essential cookies for + authentication. +

+
+
+ +
+

Terms of Service

+
+

+ The basics: Sprint is a project management tool. + Use it to organise work, track issues, and manage time. Don't use it for illegal stuff. +

+

+ Your account: You're responsible for keeping your + login details secure. Don't share your account. +

+

+ Payments: Pro plans are billed monthly or + annually. Cancel anytime from your account settings. No refunds for partial months. +

+

+ Service availability: We aim for 99.9% uptime but + can't guarantee it. We may occasionally need downtime for maintenance. +

+

+ Termination: We may suspend accounts that violate + these terms. +

+

+ Changes: We'll notify you of significant changes + to these terms via email. +

+
+
+ +
+

Questions?

+

+ Email us at{" "} + + support@sprintpm.org + {" "} + - we'll get back to you within 24 hours. +

+
+ +
+

Last updated: January 2025

+
+
+
+ + +
+ ); +} diff --git a/packages/frontend/src/pages/Landing.tsx b/packages/frontend/src/pages/Landing.tsx index 96559a3..9555ff8 100644 --- a/packages/frontend/src/pages/Landing.tsx +++ b/packages/frontend/src/pages/Landing.tsx @@ -387,21 +387,29 @@ export default function Landing() { -
); diff --git a/packages/frontend/src/pages/Plans.tsx b/packages/frontend/src/pages/Plans.tsx index aff6b2e..04dba89 100644 --- a/packages/frontend/src/pages/Plans.tsx +++ b/packages/frontend/src/pages/Plans.tsx @@ -286,6 +286,15 @@ export default function Plans() {

30-day no-risk policy

+ +
+ + The boring stuff — Privacy Policy & ToS + +
From 8c641e5df02a8b94f1476519942d56c9fa546e30 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Thu, 29 Jan 2026 11:14:11 +0000 Subject: [PATCH 25/25] sprintpm.org --- packages/backend/.env.example | 6 +++--- packages/frontend/src/components/server-configuration.tsx | 2 +- packages/frontend/src/lib/utils.ts | 2 +- packages/frontend/vite.config.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 2e28ed5..c1ab18f 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -6,8 +6,8 @@ CORS_ORIGIN=http://localhost:1420 # openssl rand -base64 32 JWT_SECRET=jwt_secret_here -S3_PUBLIC_URL=https://issuebucket.ob248.com -S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/issue +S3_PUBLIC_URL=https://images.sprintpm.org +S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint S3_ACCESS_KEY_ID=your_access_key_id S3_SECRET_ACCESS_KEY=your_secret_access_key S3_BUCKET_NAME=issue @@ -16,4 +16,4 @@ STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key STRIPE_SECRET_KEY=your_stripe_secret_key RESEND_API_KEY=re_xxxxxxxxxxxxxxxx -EMAIL_FROM=Sprint \ No newline at end of file +EMAIL_FROM=Sprint \ No newline at end of file diff --git a/packages/frontend/src/components/server-configuration.tsx b/packages/frontend/src/components/server-configuration.tsx index 779730b..4e0a085 100644 --- a/packages/frontend/src/components/server-configuration.tsx +++ b/packages/frontend/src/components/server-configuration.tsx @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { getServerURL } from "@/lib/utils"; -const DEFAULT_URL = "https://tnirps.ob248.com"; +const DEFAULT_URL = "https://server.sprintpm.org"; const formatURL = (url: string) => { if (url.endsWith("/")) { diff --git a/packages/frontend/src/lib/utils.ts b/packages/frontend/src/lib/utils.ts index 600cf20..58ebdb9 100644 --- a/packages/frontend/src/lib/utils.ts +++ b/packages/frontend/src/lib/utils.ts @@ -37,7 +37,7 @@ export function getServerURL() { let serverURL = localStorage.getItem("serverURL") || // user-defined server URL ENV_SERVER_URL || // environment variable - "https://tnirps.ob248.com"; // fallback + "https://server.sprintpm.org"; // fallback if (serverURL.endsWith("/")) { serverURL = serverURL.slice(0, -1); } diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index cf2cd86..48a6436 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig(async () => ({ server: { port: 1420, strictPort: true, - allowedHosts: ["sprint.ob248.com"], + allowedHosts: ["sprint.ob248.com", "sprintpm.org"], host: host || false, hmr: host ? {