replaced per-endpoint helpers with ts-rest contract and typed client

This commit is contained in:
2026-01-28 13:01:28 +00:00
parent aa24de2e8e
commit d6af2032db
71 changed files with 1042 additions and 1075 deletions

View File

@@ -7,12 +7,18 @@ import type {
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { issueComment } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useIssueComments(issueId?: number | null) {
return useQuery<IssueCommentResponse[]>({
queryKey: queryKeys.issueComments.byIssue(issueId ?? 0),
queryFn: () => issueComment.byIssue(issueId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.issueCommentsByIssue({
query: { issueId: issueId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as IssueCommentResponse[];
},
enabled: Boolean(issueId),
});
}
@@ -22,7 +28,12 @@ export function useCreateIssueComment() {
return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({
mutationKey: ["issue-comments", "create"],
mutationFn: issueComment.create,
mutationFn: async (input) => {
const { data, error } = await apiClient.issueCommentCreate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create comment");
return data as IssueCommentRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.issueComments.byIssue(variables.issueId),
@@ -36,7 +47,12 @@ export function useDeleteIssueComment() {
return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({
mutationKey: ["issue-comments", "delete"],
mutationFn: issueComment.delete,
mutationFn: async (input) => {
const { data, error } = await apiClient.issueCommentDelete({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to delete comment");
return data as SuccessResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all });
},

View File

@@ -12,12 +12,18 @@ import type {
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useIssues(projectId?: number | null) {
return useQuery<IssueResponse[]>({
queryKey: queryKeys.issues.byProject(projectId ?? 0),
queryFn: () => issue.byProject(projectId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.issuesByProject({
query: { projectId: projectId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as IssueResponse[];
},
enabled: Boolean(projectId),
});
}
@@ -25,7 +31,14 @@ export function useIssues(projectId?: number | null) {
export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
return useQuery<IssueResponse>({
queryKey: queryKeys.issues.byId(issueId ?? 0),
queryFn: () => issue.byId(issueId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.issueById({
query: { issueId: issueId ?? 0 },
});
if (error) throw new Error(error);
if (!data) throw new Error("issue not found");
return data as IssueResponse;
},
enabled: Boolean(issueId),
});
}
@@ -33,7 +46,13 @@ export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
queryFn: async () => {
const { data, error } = await apiClient.issuesStatusCount({
query: { organisationId: organisationId ?? 0, status: status ?? "" },
});
if (error) throw new Error(error);
return (data ?? []) as StatusCountResponse;
},
enabled: Boolean(organisationId && status),
});
}
@@ -43,7 +62,12 @@ export function useCreateIssue() {
return useMutation<IssueRecord, Error, IssueCreateRequest>({
mutationKey: ["issues", "create"],
mutationFn: issue.create,
mutationFn: async (input) => {
const { data, error } = await apiClient.issueCreate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create issue");
return data as IssueRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.byProject(variables.projectId),
@@ -57,7 +81,12 @@ export function useUpdateIssue() {
return useMutation<IssueRecord, Error, IssueUpdateRequest>({
mutationKey: ["issues", "update"],
mutationFn: issue.update,
mutationFn: async (input) => {
const { data, error } = await apiClient.issueUpdate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update issue");
return data as IssueRecord;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
@@ -69,7 +98,12 @@ export function useDeleteIssue() {
return useMutation<SuccessResponse, Error, number>({
mutationKey: ["issues", "delete"],
mutationFn: issue.delete,
mutationFn: async (issueId) => {
const { data, error } = await apiClient.issueDelete({ body: { id: issueId } });
if (error) throw new Error(error);
if (!data) throw new Error("failed to delete issue");
return data as SuccessResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
@@ -81,7 +115,11 @@ export function useReplaceIssueStatus() {
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
mutationKey: ["issues", "replace-status"],
mutationFn: issue.replaceStatus,
mutationFn: async (input) => {
const { data, error } = await apiClient.issuesReplaceStatus({ body: input });
if (error) throw new Error(error);
return data as unknown;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
@@ -91,7 +129,14 @@ export function useReplaceIssueStatus() {
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
return useQuery<TypeCountResponse>({
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""),
queryFn: () => issue.typeCount(organisationId ?? 0, type ?? ""),
queryFn: async () => {
const { data, error } = await apiClient.issuesTypeCount({
query: { organisationId: organisationId ?? 0, type: type ?? "" },
});
if (error) throw new Error(error);
if (!data) throw new Error("failed to get type count");
return data as TypeCountResponse;
},
enabled: Boolean(organisationId && type),
});
}
@@ -101,7 +146,11 @@ export function useReplaceIssueType() {
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
mutationKey: ["issues", "replace-type"],
mutationFn: issue.replaceType,
mutationFn: async (input) => {
const { data, error } = await apiClient.issuesReplaceType({ body: input });
if (error) throw new Error(error);
return data as unknown;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},

View File

@@ -2,22 +2,39 @@ import type {
OrgAddMemberRequest,
OrganisationMemberRecord,
OrganisationMemberResponse,
OrganisationRecordType,
OrganisationResponse,
OrgCreateRequest,
OrgRemoveMemberRequest,
OrgUpdateMemberRoleRequest,
OrgUpdateRequest,
SuccessResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { organisation } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useOrganisations() {
return useQuery({
return useQuery<OrganisationResponse[]>({
queryKey: queryKeys.organisations.byUser(),
queryFn: organisation.byUser,
queryFn: async () => {
const { data, error } = await apiClient.organisationsByUser();
if (error) throw new Error(error);
return (data ?? []) as OrganisationResponse[];
},
});
}
export function useOrganisationMembers(organisationId?: number | null) {
return useQuery<OrganisationMemberResponse[]>({
queryKey: queryKeys.organisations.members(organisationId ?? 0),
queryFn: () => organisation.members(organisationId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.organisationMembers({
query: { organisationId: organisationId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as OrganisationMemberResponse[];
},
enabled: Boolean(organisationId),
});
}
@@ -25,9 +42,14 @@ export function useOrganisationMembers(organisationId?: number | null) {
export function useCreateOrganisation() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<OrganisationRecordType, Error, OrgCreateRequest>({
mutationKey: ["organisations", "create"],
mutationFn: organisation.create,
mutationFn: async (input) => {
const { data, error } = await apiClient.organisationCreate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create organisation");
return data as OrganisationRecordType;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
@@ -37,9 +59,14 @@ export function useCreateOrganisation() {
export function useUpdateOrganisation() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<OrganisationRecordType, Error, OrgUpdateRequest>({
mutationKey: ["organisations", "update"],
mutationFn: organisation.update,
mutationFn: async (input) => {
const { data, error } = await apiClient.organisationUpdate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update organisation");
return data as OrganisationRecordType;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
@@ -49,9 +76,14 @@ export function useUpdateOrganisation() {
export function useDeleteOrganisation() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<SuccessResponse, Error, number>({
mutationKey: ["organisations", "delete"],
mutationFn: organisation.remove,
mutationFn: async (orgId) => {
const { data, error } = await apiClient.organisationDelete({ body: { id: orgId } });
if (error) throw new Error(error);
if (!data) throw new Error("failed to delete organisation");
return data as SuccessResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
@@ -63,7 +95,12 @@ export function useAddOrganisationMember() {
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
mutationKey: ["organisations", "members", "add"],
mutationFn: organisation.addMember,
mutationFn: async (input) => {
const { data, error } = await apiClient.organisationAddMember({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to add member");
return data as OrganisationMemberRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -75,9 +112,14 @@ export function useAddOrganisationMember() {
export function useRemoveOrganisationMember() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<SuccessResponse, Error, OrgRemoveMemberRequest>({
mutationKey: ["organisations", "members", "remove"],
mutationFn: organisation.removeMember,
mutationFn: async (input) => {
const { data, error } = await apiClient.organisationRemoveMember({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to remove member");
return data as SuccessResponse;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -89,9 +131,14 @@ export function useRemoveOrganisationMember() {
export function useUpdateOrganisationMemberRole() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<OrganisationMemberRecord, Error, OrgUpdateMemberRoleRequest>({
mutationKey: ["organisations", "members", "update-role"],
mutationFn: organisation.updateMemberRole,
mutationFn: async (input) => {
const { data, error } = await apiClient.organisationUpdateMemberRole({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update member role");
return data as OrganisationMemberRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -105,7 +152,15 @@ export function useUploadOrganisationIcon() {
return useMutation<string, Error, { file: File; organisationId: number }>({
mutationKey: ["organisations", "upload-icon"],
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
mutationFn: async ({ file, organisationId }) => {
const formData = new FormData();
formData.append("file", file);
formData.append("organisationId", `${organisationId}`);
const { data, error } = await apiClient.organisationUploadIcon({ body: formData });
if (error) throw new Error(error);
if (!data) throw new Error("failed to upload organisation icon");
return (data as { iconURL: string }).iconURL;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},

View File

@@ -3,15 +3,22 @@ import type {
ProjectRecord,
ProjectResponse,
ProjectUpdateRequest,
SuccessResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { project } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useProjects(organisationId?: number | null) {
return useQuery<ProjectResponse[]>({
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
queryFn: () => project.byOrganisation(organisationId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.projectsByOrganisation({
query: { organisationId: organisationId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as ProjectResponse[];
},
enabled: Boolean(organisationId),
});
}
@@ -21,7 +28,12 @@ export function useCreateProject() {
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
mutationKey: ["projects", "create"],
mutationFn: project.create,
mutationFn: async (input) => {
const { data, error } = await apiClient.projectCreate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create project");
return data as ProjectRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
@@ -35,7 +47,12 @@ export function useUpdateProject() {
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
mutationKey: ["projects", "update"],
mutationFn: project.update,
mutationFn: async (input) => {
const { data, error } = await apiClient.projectUpdate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update project");
return data as ProjectRecord;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
},
@@ -45,9 +62,14 @@ export function useUpdateProject() {
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<SuccessResponse, Error, number>({
mutationKey: ["projects", "delete"],
mutationFn: project.remove,
mutationFn: async (projectId) => {
const { data, error } = await apiClient.projectDelete({ body: { id: projectId } });
if (error) throw new Error(error);
if (!data) throw new Error("failed to delete project");
return data as SuccessResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
},

View File

@@ -1,12 +1,18 @@
import type { SprintCreateRequest, SprintRecord, SprintUpdateRequest } from "@sprint/shared";
import type { SprintCreateRequest, SprintRecord, SprintUpdateRequest, SuccessResponse } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { sprint } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useSprints(projectId?: number | null) {
return useQuery<SprintRecord[]>({
queryKey: queryKeys.sprints.byProject(projectId ?? 0),
queryFn: () => sprint.byProject(projectId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.sprintsByProject({
query: { projectId: projectId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as SprintRecord[];
},
enabled: Boolean(projectId),
});
}
@@ -16,7 +22,12 @@ export function useCreateSprint() {
return useMutation<SprintRecord, Error, SprintCreateRequest>({
mutationKey: ["sprints", "create"],
mutationFn: sprint.create,
mutationFn: async (input) => {
const { data, error } = await apiClient.sprintCreate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create sprint");
return data as SprintRecord;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
},
@@ -28,7 +39,12 @@ export function useUpdateSprint() {
return useMutation<SprintRecord, Error, SprintUpdateRequest>({
mutationKey: ["sprints", "update"],
mutationFn: sprint.update,
mutationFn: async (input) => {
const { data, error } = await apiClient.sprintUpdate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update sprint");
return data as SprintRecord;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
},
@@ -38,9 +54,14 @@ export function useUpdateSprint() {
export function useDeleteSprint() {
const queryClient = useQueryClient();
return useMutation({
return useMutation<SuccessResponse, Error, number>({
mutationKey: ["sprints", "delete"],
mutationFn: sprint.remove,
mutationFn: async (sprintId) => {
const { data, error } = await apiClient.sprintDelete({ body: { id: sprintId } });
if (error) throw new Error(error);
if (!data) throw new Error("failed to delete sprint");
return data as SuccessResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
},

View File

@@ -1,9 +1,13 @@
import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { timer } from "@/lib/server";
import { apiClient } from "@/lib/server";
const activeTimersQueryFn = () => timer.list({ activeOnly: true });
const activeTimersQueryFn = async () => {
const { data, error } = await apiClient.timers({ query: { activeOnly: true } });
if (error) throw new Error(error);
return (data ?? []) as TimerListItem[];
};
export function useActiveTimers(options?: { refetchInterval?: number; enabled?: boolean }) {
return useQuery<TimerListItem[]>({
@@ -29,7 +33,13 @@ export function useTimerState(issueId?: number | null, options?: { refetchInterv
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState[]>({
queryKey: queryKeys.timers.inactive(issueId ?? 0),
queryFn: () => timer.getInactive(issueId ?? 0),
queryFn: async () => {
const { data, error } = await apiClient.timerGetInactive({
query: { issueId: issueId ?? 0 },
});
if (error) throw new Error(error);
return (data ?? []) as TimerState[];
},
enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false,
@@ -41,7 +51,12 @@ export function useToggleTimer() {
return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle,
mutationFn: async (input) => {
const { data, error } = await apiClient.timerToggle({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to toggle timer");
return data as TimerState;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
@@ -54,7 +69,12 @@ export function useEndTimer() {
return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"],
mutationFn: timer.end,
mutationFn: async (input) => {
const { data, error } = await apiClient.timerEnd({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to end timer");
return data as TimerState;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });

View File

@@ -1,12 +1,19 @@
import type { UserRecord, UserUpdateRequest } from "@sprint/shared";
import type { UserResponse, UserUpdateRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { user } from "@/lib/server";
import { apiClient } from "@/lib/server";
export function useUserByUsername(username?: string | null) {
return useQuery<UserRecord>({
return useQuery<UserResponse>({
queryKey: queryKeys.users.byUsername(username ?? ""),
queryFn: () => user.byUsername(username ?? ""),
queryFn: async () => {
const { data, error } = await apiClient.userByUsername({
query: { username: username ?? "" },
});
if (error) throw new Error(error);
if (!data) throw new Error("user not found");
return data as UserResponse;
},
enabled: Boolean(username),
});
}
@@ -14,9 +21,14 @@ export function useUserByUsername(username?: string | null) {
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation<UserRecord, Error, UserUpdateRequest>({
return useMutation<UserResponse, Error, UserUpdateRequest>({
mutationKey: ["users", "update"],
mutationFn: user.update,
mutationFn: async (input) => {
const { data, error } = await apiClient.userUpdate({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to update user");
return data as UserResponse;
},
onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
},
@@ -28,7 +40,14 @@ export function useUploadAvatar() {
return useMutation<string, Error, File>({
mutationKey: ["users", "upload-avatar"],
mutationFn: user.uploadAvatar,
mutationFn: async (file) => {
const formData = new FormData();
formData.append("file", file);
const { data, error } = await apiClient.userUploadAvatar({ body: formData });
if (error) throw new Error(error);
if (!data) throw new Error("failed to upload avatar");
return (data as { avatarURL: string }).avatarURL;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
},