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

@@ -56,6 +56,7 @@
"@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -86,6 +87,7 @@
"name": "@sprint/shared",
"version": "0.1.0",
"dependencies": {
"@ts-rest/core": "^3.52.1",
"drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.5.1",
"zod": "^3.23.8",
@@ -468,6 +470,8 @@
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
"@ts-rest/core": ["@ts-rest/core@3.52.1", "", { "peerDependencies": { "@types/node": "^18.18.7 || >=20.8.4", "zod": "^3.22.3" }, "optionalPeers": ["@types/node", "zod"] }, "sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],

View File

@@ -1,4 +1,4 @@
import { Issue, IssueComment, type IssueCommentResponse, Project, User } from "@sprint/shared";
import { Issue, IssueComment, type IssueCommentResponseRecord, Project, User } from "@sprint/shared";
import { asc, eq } from "drizzle-orm";
import { db } from "../client";
@@ -23,7 +23,7 @@ export async function getIssueCommentById(id: number) {
return comment;
}
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponse[]> {
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponseRecord[]> {
const comments = await db
.select({
Comment: IssueComment,

View File

@@ -1,4 +1,4 @@
import { Issue, IssueAssignee, type IssueResponse, User, type UserRecord } from "@sprint/shared";
import { Issue, IssueAssignee, type IssueResponseRecord, User, type UserRecord } from "@sprint/shared";
import { aliasedTable, and, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
@@ -97,7 +97,7 @@ export async function getIssueByID(id: number) {
return issue;
}
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponse | null> {
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponseRecord | null> {
const Creator = aliasedTable(User, "Creator");
const [issueWithCreator] = await db
@@ -212,7 +212,7 @@ export async function replaceIssueType(organisationId: number, oldType: string,
return { updated: result.rowCount ?? 0 };
}
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponseRecord[]> {
const Creator = aliasedTable(User, "Creator");
const issuesWithCreators = await db

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@iconify/react": "^6.0.2",
"@ts-rest/core": "^3.52.1",
"@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",

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);

View File

@@ -0,0 +1,103 @@
import type { ApiError } from "@sprint/shared";
import { apiContract } from "@sprint/shared";
import type { AppRoute, AppRouter } from "@ts-rest/core";
import { checkZodSchema, initClient, isAppRoute } from "@ts-rest/core";
import { getCsrfToken, getServerURL } from "@/lib/utils";
type ApiResult<T> = {
data: T | null;
error: string | null;
status: number;
};
const rawClient = initClient(apiContract, {
baseUrl: getServerURL(),
baseHeaders: {
"X-CSRF-Token": () => getCsrfToken() || "",
},
credentials: "include",
validateResponse: true,
throwOnUnknownStatus: false,
});
function toErrorMessage(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (error && typeof error === "object") {
const maybeApiError = error as ApiError;
if (maybeApiError.details) {
const messages = Object.values(maybeApiError.details).flat();
if (messages.length > 0) return messages.join(", ");
}
if (typeof maybeApiError.error === "string") return maybeApiError.error;
}
return "unexpected error";
}
function validateRequest(route: AppRoute, input?: { body?: unknown; query?: unknown }): string | null {
if (!input) return null;
if ("body" in route && route.body && "body" in input) {
const result = checkZodSchema(input.body, route.body);
if (!result.success) {
return result.error.issues.map((issue) => issue.message).join(", ") || "invalid request body";
}
}
if ("query" in route && route.query && "query" in input) {
const result = checkZodSchema(input.query, route.query);
if (!result.success) {
return result.error.issues.map((issue) => issue.message).join(", ") || "invalid query params";
}
}
return null;
}
async function requestResult<T extends { status: number; body: unknown }>(
responsePromise: Promise<T>,
): Promise<ApiResult<T["body"]>> {
try {
const response = await responsePromise;
if (response.status >= 200 && response.status < 300) {
return { data: response.body, error: null, status: response.status };
}
return { data: null, error: toErrorMessage(response.body), status: response.status };
} catch (error) {
return { data: null, error: toErrorMessage(error), status: 0 };
}
}
type WrappedClient<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R>
? (...args: A) => Promise<ApiResult<R extends { body: infer B } ? B : unknown>>
: T[K] extends object
? WrappedClient<T[K]>
: T[K];
};
function wrapClient<TRouter extends AppRouter>(router: TRouter, client: unknown): unknown {
const entries = Object.entries(router).map(([key, route]) => {
const value = (client as Record<string, unknown>)[key];
if (isAppRoute(route) && typeof value === "function") {
return [
key,
async (input?: { body?: unknown; query?: unknown; headers?: Record<string, string> }) => {
const validationError = validateRequest(route, input);
if (validationError) {
return { data: null, error: validationError, status: 0 } as ApiResult<unknown>;
}
return requestResult(
(value as (args?: unknown) => Promise<{ status: number; body: unknown }>)(input),
);
},
];
}
if (route && typeof route === "object" && value && typeof value === "object") {
return [key, wrapClient(route as AppRouter, value)];
}
return [key, value];
});
return Object.fromEntries(entries);
}
export const apiClient = wrapClient(apiContract, rawClient) as WrappedClient<typeof rawClient>;
export type { ApiResult };

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 });
},

View File

@@ -1,29 +1,6 @@
import type { ApiError } from "@sprint/shared";
export * as issue from "@/lib/server/issue";
export * as issueComment from "@/lib/server/issue-comment";
export * as organisation from "@/lib/server/organisation";
export * as project from "@/lib/server/project";
export * as sprint from "@/lib/server/sprint";
export * as timer from "@/lib/server/timer";
export * as user from "@/lib/server/user";
export async function getErrorMessage(res: Response, fallback: string): Promise<string> {
const error = await res.json().catch(() => res.text());
if (typeof error === "string") {
return error || fallback;
}
if (error && typeof error === "object") {
if ("details" in error && error.details) {
const messages = Object.values(error.details as Record<string, string[]>).flat();
if (messages.length > 0) return messages.join(", ");
}
if ("error" in error && typeof error.error === "string") {
return error.error || fallback;
}
}
return fallback;
}
export { apiClient } from "@/lib/api-client";
export function parseError(error: ApiError | string | Error): string {
if (typeof error === "string") return error;

View File

@@ -1,19 +0,0 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byIssue(issueId: number): Promise<IssueCommentResponse[]> {
const url = new URL(`${getServerURL()}/issue-comments/by-issue`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue comments (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,29 +0,0 @@
import type { IssueCommentCreateRequest, IssueCommentRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: IssueCommentCreateRequest): Promise<IssueCommentRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create comment (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as IssueCommentRecord;
if (!data.id) {
throw new Error(`failed to create comment (${res.status})`);
}
return data;
}

View File

@@ -1,24 +0,0 @@
import type { IssueCommentDeleteRequest, SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(request: IssueCommentDeleteRequest): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue-comment/delete`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete comment (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,3 +0,0 @@
export { byIssue } from "@/lib/server/issue-comment/byIssue";
export { create } from "@/lib/server/issue-comment/create";
export { remove as delete } from "@/lib/server/issue-comment/delete";

View File

@@ -1,19 +0,0 @@
import type { IssueByIdQuery, IssueResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byId(issueId: IssueByIdQuery["issueId"]): Promise<IssueResponse> {
const url = new URL(`${getServerURL()}/issue/by-id`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,19 +0,0 @@
import type { IssueResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byProject(projectId: number): Promise<IssueResponse[]> {
const url = new URL(`${getServerURL()}/issues/by-project`);
url.searchParams.set("projectId", `${projectId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,28 +0,0 @@
import type { IssueCreateRequest, IssueRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: IssueCreateRequest): Promise<IssueRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create issue (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as IssueRecord;
if (!data.id) {
throw new Error(`failed to create issue (${res.status})`);
}
return data;
}

View File

@@ -1,24 +0,0 @@
import type { SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(issueId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({ id: issueId }),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete issue (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,9 +0,0 @@
export { byId } from "@/lib/server/issue/byId";
export { byProject } from "@/lib/server/issue/byProject";
export { create } from "@/lib/server/issue/create";
export { remove as delete } from "@/lib/server/issue/delete";
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
export { replaceType } from "@/lib/server/issue/replaceType";
export { statusCount } from "@/lib/server/issue/statusCount";
export { typeCount } from "@/lib/server/issue/typeCount";
export { update } from "@/lib/server/issue/update";

View File

@@ -1,24 +0,0 @@
import type { IssuesReplaceStatusRequest, ReplaceStatusResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function replaceStatus(request: IssuesReplaceStatusRequest): Promise<ReplaceStatusResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issues/replace-status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to replace status (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { IssuesReplaceTypeRequest, ReplaceTypeResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function replaceType(request: IssuesReplaceTypeRequest): Promise<ReplaceTypeResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issues/replace-type`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to replace type (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,20 +0,0 @@
import type { StatusCountResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function statusCount(organisationId: number, status: string): Promise<StatusCountResponse> {
const url = new URL(`${getServerURL()}/issues/status-count`);
url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("status", status);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,20 +0,0 @@
import type { TypeCountResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function typeCount(organisationId: number, type: string): Promise<TypeCountResponse> {
const url = new URL(`${getServerURL()}/issues/type-count`);
url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("type", type);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get issue type count (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { IssueRecord, IssueUpdateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function update(input: IssueUpdateRequest): Promise<IssueRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(input),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update issue (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { OrgAddMemberRequest, OrganisationMemberRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function addMember(request: OrgAddMemberRequest): Promise<OrganisationMemberRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/add-member`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to add member (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,16 +0,0 @@
import type { OrganisationResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byUser(): Promise<OrganisationResponse[]> {
const res = await fetch(`${getServerURL()}/organisations/by-user`, {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get organisations (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,28 +0,0 @@
import type { OrganisationRecord, OrgCreateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: OrgCreateRequest): Promise<OrganisationRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create organisation (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as OrganisationRecord;
if (!data.id) {
throw new Error(`failed to create organisation (${res.status})`);
}
return data;
}

View File

@@ -1,24 +0,0 @@
import type { SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(organisationId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({ id: organisationId }),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,9 +0,0 @@
export { addMember } from "@/lib/server/organisation/addMember";
export { byUser } from "@/lib/server/organisation/byUser";
export { create } from "@/lib/server/organisation/create";
export { remove } from "@/lib/server/organisation/delete";
export { members } from "@/lib/server/organisation/members";
export { removeMember } from "@/lib/server/organisation/removeMember";
export { update } from "@/lib/server/organisation/update";
export { updateMemberRole } from "@/lib/server/organisation/updateMemberRole";
export { uploadIcon } from "@/lib/server/organisation/uploadIcon";

View File

@@ -1,19 +0,0 @@
import type { OrganisationMemberResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function members(organisationId: number): Promise<OrganisationMemberResponse[]> {
const url = new URL(`${getServerURL()}/organisation/members`);
url.searchParams.set("organisationId", `${organisationId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get members (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { OrgRemoveMemberRequest, SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function removeMember(request: OrgRemoveMemberRequest): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/remove-member`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to remove member (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { OrganisationRecord, OrgUpdateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function update(input: OrgUpdateRequest): Promise<OrganisationRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(input),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update organisation (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,26 +0,0 @@
import type { OrganisationMemberRecord, OrgUpdateMemberRoleRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function updateMemberRole(
request: OrgUpdateMemberRoleRequest,
): Promise<OrganisationMemberRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update-member-role`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update member role (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,42 +0,0 @@
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function uploadIcon(file: File, organisationId: number): Promise<string> {
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds 5MB limit");
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
}
const formData = new FormData();
formData.append("file", file);
formData.append("organisationId", organisationId.toString());
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(`${getServerURL()}/organisation/upload-icon`, {
method: "POST",
headers,
body: formData,
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `Failed to upload icon (${res.status})`);
throw new Error(message);
}
const data = await res.json();
if (data.iconURL) {
return data.iconURL;
}
throw new Error("Failed to upload icon");
}

View File

@@ -1,19 +0,0 @@
import type { ProjectResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byOrganisation(organisationId: number): Promise<ProjectResponse[]> {
const url = new URL(`${getServerURL()}/projects/by-organisation`);
url.searchParams.set("organisationId", `${organisationId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,28 +0,0 @@
import type { ProjectCreateRequest, ProjectRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(request: ProjectCreateRequest): Promise<ProjectRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create project (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as ProjectRecord;
if (!data.id) {
throw new Error(`failed to create project (${res.status})`);
}
return data;
}

View File

@@ -1,24 +0,0 @@
import type { SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(projectId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({ id: projectId }),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete project (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,4 +0,0 @@
export { byOrganisation } from "@/lib/server/project/byOrganisation";
export { create } from "@/lib/server/project/create";
export { remove } from "@/lib/server/project/delete";
export { update } from "@/lib/server/project/update";

View File

@@ -1,24 +0,0 @@
import type { ProjectRecord, ProjectUpdateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function update(input: ProjectUpdateRequest): Promise<ProjectRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(input),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update project (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,19 +0,0 @@
import type { SprintRecord } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byProject(projectId: number): Promise<SprintRecord[]> {
const url = new URL(`${getServerURL()}/sprints/by-project`);
url.searchParams.set("projectId", `${projectId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get sprints (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,31 +0,0 @@
import type { SprintCreateRequest, SprintRecord } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function create(input: SprintCreateRequest): Promise<SprintRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({
...input,
name: input.name.trim(),
}),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to create sprint (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as SprintRecord;
if (!data.id) {
throw new Error(`failed to create sprint (${res.status})`);
}
return data;
}

View File

@@ -1,24 +0,0 @@
import type { SuccessResponse } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function remove(sprintId: number): Promise<SuccessResponse> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/delete`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({ id: sprintId }),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,4 +0,0 @@
export { byProject } from "@/lib/server/sprint/byProject";
export { create } from "@/lib/server/sprint/create";
export { remove } from "@/lib/server/sprint/delete";
export { update } from "@/lib/server/sprint/update";

View File

@@ -1,27 +0,0 @@
import type { SprintRecord, SprintUpdateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function update(input: SprintUpdateRequest): Promise<SprintRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify({
...input,
name: input.name?.trim(),
}),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update sprint (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { TimerEndRequest, TimerState } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function end(request: TimerEndRequest): Promise<TimerState> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/timer/end`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to end timer (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,19 +0,0 @@
import type { TimerState } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function get(issueId: number): Promise<TimerState> {
const url = new URL(`${getServerURL()}/timer/get`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timer (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,20 +0,0 @@
import type { TimerState } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function getInactive(issueId: number): Promise<TimerState[]> {
const url = new URL(`${getServerURL()}/timer/get-inactive`);
url.searchParams.set("issueId", `${issueId}`);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as TimerState[];
return data ?? [];
}

View File

@@ -1,5 +0,0 @@
export { end } from "@/lib/server/timer/end";
export { get } from "@/lib/server/timer/get";
export { getInactive } from "@/lib/server/timer/getInactive";
export { list } from "@/lib/server/timer/list";
export { toggle } from "@/lib/server/timer/toggle";

View File

@@ -1,32 +0,0 @@
import type { TimerListItem } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
type TimerListInput = {
limit?: number;
offset?: number;
activeOnly?: boolean;
};
export async function list(input: TimerListInput = {}): Promise<TimerListItem[]> {
const url = new URL(`${getServerURL()}/timers`);
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
if (input.activeOnly != null) url.searchParams.set("activeOnly", input.activeOnly ? "true" : "false");
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(url.toString(), {
headers,
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,24 +0,0 @@
import type { TimerState, TimerToggleRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function toggle(request: TimerToggleRequest): Promise<TimerState> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/timer/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to toggle timer (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,19 +0,0 @@
import type { UserRecord } from "@sprint/shared";
import { getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function byUsername(username: string): Promise<UserRecord> {
const url = new URL(`${getServerURL()}/user/by-username`);
url.searchParams.set("username", username);
const res = await fetch(url.toString(), {
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to get user (${res.status})`);
throw new Error(message);
}
return res.json();
}

View File

@@ -1,3 +0,0 @@
export { byUsername } from "@/lib/server/user/byUsername";
export { update } from "@/lib/server/user/update";
export { uploadAvatar } from "@/lib/server/user/uploadAvatar";

View File

@@ -1,28 +0,0 @@
import type { UserRecord, UserUpdateRequest } from "@sprint/shared";
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function update(request: UserUpdateRequest): Promise<UserRecord> {
const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/user/update`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
},
body: JSON.stringify(request),
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `failed to update user (${res.status})`);
throw new Error(message);
}
const data = (await res.json()) as UserRecord;
if (!data.id) {
throw new Error(`failed to update user (${res.status})`);
}
return data;
}

View File

@@ -1,41 +0,0 @@
import { getCsrfToken, getServerURL } from "@/lib/utils";
import { getErrorMessage } from "..";
export async function uploadAvatar(file: File): Promise<string> {
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
if (file.size > MAX_FILE_SIZE) {
throw new Error("File size exceeds 5MB limit");
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
}
const formData = new FormData();
formData.append("file", file);
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) headers["X-CSRF-Token"] = csrfToken;
const res = await fetch(`${getServerURL()}/user/upload-avatar`, {
method: "POST",
headers,
body: formData,
credentials: "include",
});
if (!res.ok) {
const message = await getErrorMessage(res, `Failed to upload avatar (${res.status})`);
throw new Error(message);
}
const data = await res.json();
if (data.avatarURL) {
return data.avatarURL;
}
throw new Error("Failed to upload avatar");
}

View File

@@ -10,6 +10,7 @@
"typescript": "^5.8.3"
},
"dependencies": {
"@ts-rest/core": "^3.52.1",
"drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.5.1",
"zod": "^3.23.8"

View File

@@ -57,6 +57,7 @@ export const AuthResponseSchema = z.object({
name: z.string(),
username: z.string(),
avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
}),
csrfToken: z.string(),
});
@@ -410,6 +411,7 @@ export const UserResponseSchema = z.object({
name: z.string(),
username: z.string(),
avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
});
@@ -434,7 +436,7 @@ export const IssueResponseSchema = z.object({
Assignees: z.array(UserResponseSchema),
});
export type IssueResponseType = z.infer<typeof IssueResponseSchema>;
export type IssueResponse = z.infer<typeof IssueResponseSchema>;
export const IssueCommentRecordSchema = z.object({
id: z.number(),
@@ -450,18 +452,23 @@ export const IssueCommentResponseSchema = z.object({
User: UserResponseSchema,
});
export type IssueCommentResponseType = z.infer<typeof IssueCommentResponseSchema>;
export type IssueCommentResponse = z.infer<typeof IssueCommentResponseSchema>;
export const OrganisationRecordSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string().nullable(),
slug: z.string(),
iconURL: z.string().nullable().optional(),
statuses: z.record(z.string()),
features: z.record(z.boolean()),
issueTypes: z.record(z.object({ icon: z.string(), color: z.string() })),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
});
export type OrganisationRecordType = z.infer<typeof OrganisationRecordSchema>;
export const OrganisationMemberRecordSchema = z.object({
id: z.number(),
organisationId: z.number(),
@@ -470,12 +477,22 @@ export const OrganisationMemberRecordSchema = z.object({
createdAt: z.string().nullable().optional(),
});
export type OrganisationMemberRecordType = z.infer<typeof OrganisationMemberRecordSchema>;
export const OrganisationResponseSchema = z.object({
Organisation: OrganisationRecordSchema,
OrganisationMember: OrganisationMemberRecordSchema,
});
export type OrganisationResponseType = z.infer<typeof OrganisationResponseSchema>;
export type OrganisationResponse = z.infer<typeof OrganisationResponseSchema>;
export const OrganisationMemberResponseSchema = z.object({
OrganisationMember: OrganisationMemberRecordSchema,
Organisation: OrganisationRecordSchema,
User: UserResponseSchema,
});
export type OrganisationMemberResponse = z.infer<typeof OrganisationMemberResponseSchema>;
export const ProjectRecordSchema = z.object({
id: z.number(),
@@ -491,7 +508,14 @@ export const ProjectResponseSchema = z.object({
User: UserResponseSchema,
});
export type ProjectResponseType = z.infer<typeof ProjectResponseSchema>;
export type ProjectResponse = z.infer<typeof ProjectResponseSchema>;
export const ProjectWithCreatorResponseSchema = z.object({
Project: ProjectRecordSchema,
User: UserResponseSchema,
});
export type ProjectWithCreatorResponse = z.infer<typeof ProjectWithCreatorResponseSchema>;
export const SprintRecordSchema = z.object({
id: z.number(),
@@ -503,7 +527,7 @@ export const SprintRecordSchema = z.object({
createdAt: z.string().nullable().optional(),
});
export type SprintResponseType = z.infer<typeof SprintRecordSchema>;
export type SprintResponse = z.infer<typeof SprintRecordSchema>;
export const TimerStateSchema = z
.object({

View File

@@ -0,0 +1,580 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
ApiErrorSchema,
AuthResponseSchema,
IssueByIdQuerySchema,
IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema,
IssueCommentRecordSchema,
IssueCommentResponseSchema,
IssueCommentsByIssueQuerySchema,
IssueCreateRequestSchema,
IssueDeleteRequestSchema,
IssueRecordSchema,
IssueResponseSchema,
IssuesByProjectQuerySchema,
IssuesReplaceStatusRequestSchema,
IssuesReplaceTypeRequestSchema,
IssuesStatusCountQuerySchema,
IssuesTypeCountQuerySchema,
IssueUpdateRequestSchema,
LoginRequestSchema,
OrgAddMemberRequestSchema,
OrganisationMemberRecordSchema,
OrganisationMemberResponseSchema,
OrganisationRecordSchema,
OrganisationResponseSchema,
OrgByIdQuerySchema,
OrgCreateRequestSchema,
OrgDeleteRequestSchema,
OrgMembersQuerySchema,
OrgRemoveMemberRequestSchema,
OrgUpdateMemberRoleRequestSchema,
OrgUpdateRequestSchema,
ProjectByCreatorQuerySchema,
ProjectByIdQuerySchema,
ProjectByOrgQuerySchema,
ProjectCreateRequestSchema,
ProjectDeleteRequestSchema,
ProjectRecordSchema,
ProjectResponseSchema,
ProjectUpdateRequestSchema,
ProjectWithCreatorResponseSchema,
RegisterRequestSchema,
ReplaceStatusResponseSchema,
ReplaceTypeResponseSchema,
SprintCreateRequestSchema,
SprintDeleteRequestSchema,
SprintRecordSchema,
SprintsByProjectQuerySchema,
SprintUpdateRequestSchema,
StatusCountResponseSchema,
SuccessResponseSchema,
TimerEndRequestSchema,
TimerGetQuerySchema,
TimerListItemSchema,
TimerStateSchema,
TimerToggleRequestSchema,
TypeCountResponseSchema,
UserByUsernameQuerySchema,
UserResponseSchema,
UserUpdateRequestSchema,
} from "./api-schemas";
const c = initContract();
const csrfHeaderSchema = z.object({
"X-CSRF-Token": z.string(),
});
const emptyBodySchema = z.object({});
const timerInactiveResponseSchema = z.array(
z.object({
id: z.number(),
userId: z.number(),
issueId: z.number().nullable(),
timestamps: z.array(z.string()),
endedAt: z.string().nullable(),
createdAt: z.string().nullable().optional(),
workTimeMs: z.number(),
breakTimeMs: z.number(),
}),
);
const timerListItemResponseSchema = z.union([
TimerListItemSchema,
z.object({
id: z.number(),
userId: z.number(),
issueId: z.number().nullable(),
timestamps: z.array(z.string()),
endedAt: z.string().nullable(),
createdAt: z.string().nullable().optional(),
workTimeMs: z.number(),
breakTimeMs: z.number(),
isRunning: z.boolean(),
}),
]);
const timersQuerySchema = z.object({
limit: z.coerce.number().int().positive().optional(),
offset: z.coerce.number().int().nonnegative().optional(),
activeOnly: z.coerce.boolean().optional(),
});
export const apiContract = c.router({
authRegister: {
method: "POST",
path: "/auth/register",
body: RegisterRequestSchema,
responses: {
200: AuthResponseSchema,
400: ApiErrorSchema,
409: ApiErrorSchema,
},
},
authLogin: {
method: "POST",
path: "/auth/login",
body: LoginRequestSchema,
responses: {
200: AuthResponseSchema,
401: ApiErrorSchema,
},
},
authLogout: {
method: "POST",
path: "/auth/logout",
body: emptyBodySchema,
responses: {
200: SuccessResponseSchema,
},
headers: csrfHeaderSchema,
},
authMe: {
method: "GET",
path: "/auth/me",
responses: {
200: AuthResponseSchema,
401: ApiErrorSchema,
404: ApiErrorSchema,
},
},
userByUsername: {
method: "GET",
path: "/user/by-username",
query: UserByUsernameQuerySchema,
responses: {
200: UserResponseSchema,
404: ApiErrorSchema,
},
},
userUpdate: {
method: "POST",
path: "/user/update",
body: UserUpdateRequestSchema,
responses: {
200: UserResponseSchema,
400: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
userUploadAvatar: {
method: "POST",
path: "/user/upload-avatar",
contentType: "multipart/form-data",
body: z.instanceof(FormData),
responses: {
200: z.object({ avatarURL: z.string() }),
400: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issueCreate: {
method: "POST",
path: "/issue/create",
body: IssueCreateRequestSchema,
responses: {
200: IssueRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issueById: {
method: "GET",
path: "/issue/by-id",
query: IssueByIdQuerySchema,
responses: {
200: IssueResponseSchema,
404: ApiErrorSchema,
},
},
issueUpdate: {
method: "POST",
path: "/issue/update",
body: IssueUpdateRequestSchema,
responses: {
200: IssueRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issueDelete: {
method: "POST",
path: "/issue/delete",
body: IssueDeleteRequestSchema,
responses: {
200: SuccessResponseSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issuesByProject: {
method: "GET",
path: "/issues/by-project",
query: IssuesByProjectQuerySchema,
responses: {
200: z.array(IssueResponseSchema),
},
},
issuesAll: {
method: "GET",
path: "/issues/all",
responses: {
200: z.array(IssueResponseSchema),
},
},
issuesReplaceStatus: {
method: "POST",
path: "/issues/replace-status",
body: IssuesReplaceStatusRequestSchema,
responses: {
200: ReplaceStatusResponseSchema,
403: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issuesReplaceType: {
method: "POST",
path: "/issues/replace-type",
body: IssuesReplaceTypeRequestSchema,
responses: {
200: ReplaceTypeResponseSchema,
403: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issuesStatusCount: {
method: "GET",
path: "/issues/status-count",
query: IssuesStatusCountQuerySchema,
responses: {
200: StatusCountResponseSchema,
},
},
issuesTypeCount: {
method: "GET",
path: "/issues/type-count",
query: IssuesTypeCountQuerySchema,
responses: {
200: TypeCountResponseSchema,
},
},
issueCommentsByIssue: {
method: "GET",
path: "/issue-comments/by-issue",
query: IssueCommentsByIssueQuerySchema,
responses: {
200: z.array(IssueCommentResponseSchema),
},
},
issueCommentCreate: {
method: "POST",
path: "/issue-comment/create",
body: IssueCommentCreateRequestSchema,
responses: {
200: IssueCommentRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
issueCommentDelete: {
method: "POST",
path: "/issue-comment/delete",
body: IssueCommentDeleteRequestSchema,
responses: {
200: SuccessResponseSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationCreate: {
method: "POST",
path: "/organisation/create",
body: OrgCreateRequestSchema,
responses: {
200: OrganisationRecordSchema,
400: ApiErrorSchema,
409: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationById: {
method: "GET",
path: "/organisation/by-id",
query: OrgByIdQuerySchema,
responses: {
200: OrganisationRecordSchema,
404: ApiErrorSchema,
},
},
organisationUpdate: {
method: "POST",
path: "/organisation/update",
body: OrgUpdateRequestSchema,
responses: {
200: OrganisationRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationDelete: {
method: "POST",
path: "/organisation/delete",
body: OrgDeleteRequestSchema,
responses: {
200: SuccessResponseSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationUploadIcon: {
method: "POST",
path: "/organisation/upload-icon",
contentType: "multipart/form-data",
body: z.instanceof(FormData),
responses: {
200: z.object({ iconURL: z.string() }),
400: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationAddMember: {
method: "POST",
path: "/organisation/add-member",
body: OrgAddMemberRequestSchema,
responses: {
200: OrganisationMemberRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
409: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationMembers: {
method: "GET",
path: "/organisation/members",
query: OrgMembersQuerySchema,
responses: {
200: z.array(OrganisationMemberResponseSchema),
},
},
organisationRemoveMember: {
method: "POST",
path: "/organisation/remove-member",
body: OrgRemoveMemberRequestSchema,
responses: {
200: SuccessResponseSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationUpdateMemberRole: {
method: "POST",
path: "/organisation/update-member-role",
body: OrgUpdateMemberRoleRequestSchema,
responses: {
200: OrganisationMemberRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
organisationsByUser: {
method: "GET",
path: "/organisations/by-user",
responses: {
200: z.array(OrganisationResponseSchema),
},
},
projectCreate: {
method: "POST",
path: "/project/create",
body: ProjectCreateRequestSchema,
responses: {
200: ProjectRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
409: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
projectUpdate: {
method: "POST",
path: "/project/update",
body: ProjectUpdateRequestSchema,
responses: {
200: ProjectRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
projectDelete: {
method: "POST",
path: "/project/delete",
body: ProjectDeleteRequestSchema,
responses: {
200: SuccessResponseSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
projectWithCreator: {
method: "GET",
path: "/project/with-creator",
query: ProjectByIdQuerySchema,
responses: {
200: ProjectWithCreatorResponseSchema,
404: ApiErrorSchema,
},
},
projectsByCreator: {
method: "GET",
path: "/projects/by-creator",
query: ProjectByCreatorQuerySchema,
responses: {
200: z.array(ProjectWithCreatorResponseSchema),
},
},
projectsByOrganisation: {
method: "GET",
path: "/projects/by-organisation",
query: ProjectByOrgQuerySchema,
responses: {
200: z.array(ProjectResponseSchema),
},
},
projectsAll: {
method: "GET",
path: "/projects/all",
responses: {
200: z.array(ProjectWithCreatorResponseSchema),
},
},
projectsWithCreators: {
method: "GET",
path: "/projects/with-creators",
responses: {
200: z.array(ProjectWithCreatorResponseSchema),
},
},
sprintCreate: {
method: "POST",
path: "/sprint/create",
body: SprintCreateRequestSchema,
responses: {
200: SprintRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
sprintUpdate: {
method: "POST",
path: "/sprint/update",
body: SprintUpdateRequestSchema,
responses: {
200: SprintRecordSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
sprintDelete: {
method: "POST",
path: "/sprint/delete",
body: SprintDeleteRequestSchema,
responses: {
200: SuccessResponseSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
sprintsByProject: {
method: "GET",
path: "/sprints/by-project",
query: SprintsByProjectQuerySchema,
responses: {
200: z.array(SprintRecordSchema),
},
},
timerToggle: {
method: "POST",
path: "/timer/toggle",
body: TimerToggleRequestSchema,
responses: {
200: TimerStateSchema,
400: ApiErrorSchema,
403: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
timerEnd: {
method: "POST",
path: "/timer/end",
body: TimerEndRequestSchema,
responses: {
200: TimerStateSchema,
400: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
timerGet: {
method: "GET",
path: "/timer/get",
query: TimerGetQuerySchema,
responses: {
200: TimerStateSchema,
},
},
timerGetInactive: {
method: "GET",
path: "/timer/get-inactive",
query: TimerGetQuerySchema,
responses: {
200: timerInactiveResponseSchema.nullable(),
},
},
timers: {
method: "GET",
path: "/timers",
query: timersQuerySchema,
responses: {
200: z.array(timerListItemResponseSchema),
},
},
});
export type ApiContract = typeof apiContract;

View File

@@ -4,11 +4,11 @@ export type {
IssueByIdQuery,
IssueCommentCreateRequest,
IssueCommentDeleteRequest,
IssueCommentResponseType,
IssueCommentResponse,
IssueCommentsByIssueQuery,
IssueCreateRequest,
IssueDeleteRequest,
IssueResponseType,
IssueResponse,
IssuesByProjectQuery,
IssuesReplaceStatusRequest,
IssuesReplaceTypeRequest,
@@ -17,7 +17,10 @@ export type {
IssueUpdateRequest,
LoginRequest,
OrgAddMemberRequest,
OrganisationResponseType,
OrganisationMemberRecordType,
OrganisationMemberResponse,
OrganisationRecordType,
OrganisationResponse,
OrgByIdQuery,
OrgCreateRequest,
OrgDeleteRequest,
@@ -30,14 +33,15 @@ export type {
ProjectByOrgQuery,
ProjectCreateRequest,
ProjectDeleteRequest,
ProjectResponseType,
ProjectResponse,
ProjectUpdateRequest,
ProjectWithCreatorResponse,
RegisterRequest,
ReplaceStatusResponse,
ReplaceTypeResponse,
SprintCreateRequest,
SprintDeleteRequest,
SprintResponseType,
SprintResponse,
SprintsByProjectQuery,
SprintUpdateRequest,
StatusCountResponse,
@@ -76,6 +80,7 @@ export {
LoginRequestSchema,
OrgAddMemberRequestSchema,
OrganisationMemberRecordSchema,
OrganisationMemberResponseSchema,
OrganisationRecordSchema,
OrganisationResponseSchema,
OrgByIdQuerySchema,
@@ -93,6 +98,7 @@ export {
ProjectRecordSchema,
ProjectResponseSchema,
ProjectUpdateRequestSchema,
ProjectWithCreatorResponseSchema,
RegisterRequestSchema,
ReplaceStatusResponseSchema,
ReplaceTypeResponseSchema,
@@ -129,25 +135,27 @@ export {
USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH,
} from "./constants";
export type { ApiContract } from "./contract";
export { apiContract } from "./contract";
export type {
IconStyle,
IssueAssigneeInsert,
IssueAssigneeRecord,
IssueCommentInsert,
IssueCommentRecord,
IssueCommentResponse,
IssueCommentResponse as IssueCommentResponseRecord,
IssueInsert,
IssueRecord,
IssueResponse,
IssueResponse as IssueResponseRecord,
OrganisationInsert,
OrganisationMemberInsert,
OrganisationMemberRecord,
OrganisationMemberResponse,
OrganisationMemberResponse as OrganisationMemberResponseRecord,
OrganisationRecord,
OrganisationResponse,
OrganisationResponse as OrganisationResponseRecord,
ProjectInsert,
ProjectRecord,
ProjectResponse,
ProjectResponse as ProjectResponseRecord,
SessionInsert,
SessionRecord,
SprintInsert,