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

@@ -1,4 +1,4 @@
import type { UserRecord } from "@sprint/shared";
import type { UserResponse } from "@sprint/shared";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Field } from "@/components/ui/field";
import { useAddOrganisationMember } from "@/lib/query/hooks";
import { parseError, user } from "@/lib/server";
import { apiClient, parseError } from "@/lib/server";
export function AddMember({
organisationId,
@@ -23,7 +23,7 @@ export function AddMember({
organisationId: number;
existingMembers: string[];
trigger?: React.ReactNode;
onSuccess?: (user: UserRecord) => void | Promise<void>;
onSuccess?: (user: UserResponse) => void | Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
@@ -62,7 +62,10 @@ export function AddMember({
setSubmitting(true);
try {
const userData: UserRecord = await user.byUsername(username);
const { data, error } = await apiClient.userByUsername({ query: { username } });
if (error) throw new Error(error);
if (!data) throw new Error("user not found");
const userData = data as UserResponse;
const userId = userData.id;
await addMember.mutateAsync({ organisationId, userId, role: "member" });
setOpen(false);

View File

@@ -34,10 +34,8 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
const sortedComments = useMemo(() => {
return [...data].sort((a, b) => {
const aDate =
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0);
const bDate =
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
const aDate = a.Comment.createdAt ? new Date(a.Comment.createdAt) : new Date(0);
const bDate = b.Comment.createdAt ? new Date(b.Comment.createdAt) : new Date(0);
return aDate.getTime() - bDate.getTime();
});
}, [data]);

View File

@@ -1,4 +1,4 @@
import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared";
import type { IssueResponse, SprintRecord, UserResponse } from "@sprint/shared";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { IssueComments } from "@/components/issue-comments";
@@ -51,7 +51,7 @@ export function IssueDetails({
issueData: IssueResponse;
projectKey: string;
sprints: SprintRecord[];
members: UserRecord[];
members: UserResponse[];
statuses: Record<string, string>;
onClose: () => void;
onDelete?: () => void;

View File

@@ -1,4 +1,4 @@
import type { UserRecord } from "@sprint/shared";
import type { UserResponse } from "@sprint/shared";
import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { UserSelect } from "@/components/user-select";
@@ -9,10 +9,10 @@ export function MultiAssigneeSelect({
onChange,
fallbackUsers = [],
}: {
users: UserRecord[];
users: UserResponse[];
assigneeIds: string[];
onChange: (assigneeIds: string[]) => void;
fallbackUsers?: UserRecord[];
fallbackUsers?: UserResponse[];
}) {
const handleAssigneeChange = (index: number, value: string) => {
// if set to "unassigned" and there are other rows, remove this row

View File

@@ -2,7 +2,7 @@ import {
ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH,
type OrganisationRecord,
type OrganisationRecordType,
} from "@sprint/shared";
import { type FormEvent, useEffect, useState } from "react";
import { toast } from "sonner";
@@ -41,10 +41,10 @@ export function OrganisationForm({
onOpenChange: controlledOnOpenChange,
}: {
trigger?: React.ReactNode;
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
completeAction?: (org: OrganisationRecordType) => void | Promise<void>;
errorAction?: (errorMessage: string) => void | Promise<void>;
mode?: "create" | "edit";
existingOrganisation?: OrganisationRecord;
existingOrganisation?: OrganisationRecordType;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {

View File

@@ -51,7 +51,7 @@ import {
useUpdateOrganisationMemberRole,
} from "@/lib/query/hooks";
import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server";
import { apiClient } from "@/lib/server";
import { capitalise, unCamelCase } from "@/lib/utils";
import { Switch } from "./ui/switch";
@@ -370,8 +370,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const handleRemoveStatusClick = async (status: string) => {
if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return;
try {
const data = await issue.statusCount(selectedOrganisation.Organisation.id, status);
const count = data.find((item) => item.status === status)?.count ?? 0;
const { data, error } = await apiClient.issuesStatusCount({
query: { organisationId: selectedOrganisation.Organisation.id, status },
});
if (error) throw new Error(error);
const statusCounts = (data ?? []) as { status: string; count: number }[];
const count = statusCounts.find((item) => item.status === status)?.count ?? 0;
if (count > 0) {
setStatusToRemove(status);
setIssuesUsingStatus(count);
@@ -546,8 +550,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const handleRemoveTypeClick = async (typeName: string) => {
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
try {
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName);
const count = data.count ?? 0;
const { data, error } = await apiClient.issuesTypeCount({
query: { organisationId: selectedOrganisation.Organisation.id, type: typeName },
});
if (error) throw new Error(error);
const typeCount = (data ?? { count: 0 }) as { count: number };
const count = typeCount.count ?? 0;
if (count > 0) {
setTypeToRemove(typeName);
setIssuesUsingType(count);

View File

@@ -1,4 +1,4 @@
import type { UserRecord } from "@sprint/shared";
import type { UserResponse } from "@sprint/shared";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import Loading from "@/components/loading";
@@ -6,8 +6,8 @@ import { LoginModal } from "@/components/login-modal";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue {
user: UserRecord | null;
setUser: (user: UserRecord) => void;
user: UserResponse | null;
setUser: (user: UserResponse) => void;
isLoading: boolean;
}
@@ -28,7 +28,7 @@ export function useSessionSafe(): SessionContextValue | null {
}
// for use inside RequireAuth
export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } {
export function useAuthenticatedSession(): { user: UserResponse; setUser: (user: UserResponse) => void } {
const { user, setUser } = useSession();
if (!user) {
throw new Error("useAuthenticatedSession must be used within RequireAuth");
@@ -37,11 +37,11 @@ export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: U
}
export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUserState] = useState<UserRecord | null>(null);
const [user, setUserState] = useState<UserResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetched = useRef(false);
const setUser = useCallback((user: UserRecord) => {
const setUser = useCallback((user: UserResponse) => {
setUserState(user);
localStorage.setItem("user", JSON.stringify(user));
}, []);
@@ -57,7 +57,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
const data = (await res.json()) as { user: UserResponse; csrfToken: string };
setUser(data.user);
setCsrfToken(data.csrfToken);
})

View File

@@ -1,8 +1,8 @@
import type { UserRecord } from "@sprint/shared";
import type { UserResponse } from "@sprint/shared";
import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
export default function SmallUserDisplay({ user, className }: { user: UserRecord; className?: string }) {
export default function SmallUserDisplay({ user, className }: { user: UserResponse; className?: string }) {
return (
<div className={cn("flex gap-2 items-center", className)}>
<Avatar

View File

@@ -1,4 +1,4 @@
import type { SprintRecord, UserRecord } from "@sprint/shared";
import type { SprintRecord } from "@sprint/shared";
import { useState } from "react";
import SmallSprintDisplay from "@/components/small-sprint-display";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -12,7 +12,6 @@ export function SprintSelect({
sprints: SprintRecord[];
value: string;
onChange: (value: string) => void;
fallbackUser?: UserRecord | null;
placeholder?: string;
}) {
const [isOpen, setIsOpen] = useState(false);

View File

@@ -1,4 +1,4 @@
import type { UserRecord } from "@sprint/shared";
import type { UserResponse } from "@sprint/shared";
import { useState } from "react";
import SmallUserDisplay from "@/components/small-user-display";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -10,10 +10,10 @@ export function UserSelect({
fallbackUser,
placeholder = "Select user",
}: {
users: UserRecord[];
users: UserResponse[];
value: string;
onChange: (value: string) => void;
fallbackUser?: UserRecord | null;
fallbackUser?: UserResponse | null;
placeholder?: string;
}) {
const [isOpen, setIsOpen] = useState(false);