From c2f5a9abdb0228263a9a7c13448dcbaa6705342d Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Wed, 28 Jan 2026 17:23:20 +0000 Subject: [PATCH] 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,