refactored frontend api helpers to promise interface

This commit is contained in:
Oliver Bryan
2026-01-20 16:59:32 +00:00
parent 11bf3e68f8
commit 45343571f5
31 changed files with 253 additions and 553 deletions

View File

@@ -7,13 +7,26 @@ export * as sprint from "@/lib/server/sprint";
export * as timer from "@/lib/server/timer"; export * as timer from "@/lib/server/timer";
export * as user from "@/lib/server/user"; export * as user from "@/lib/server/user";
export type ServerQueryInput<T = unknown> = { export async function getErrorMessage(res: Response, fallback: string): Promise<string> {
onSuccess?: (data: T, res: Response) => void; const error = await res.json().catch(() => res.text());
onError?: (error: ApiError | string) => void; 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 function parseError(error: ApiError | string): string { export function parseError(error: ApiError | string | Error): string {
if (typeof error === "string") return error; if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (error.details) { if (error.details) {
const messages = Object.values(error.details).flat(); const messages = Object.values(error.details).flat();
return messages.join(", "); return messages.join(", ");

View File

@@ -1,13 +1,8 @@
import type { IssueResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function byProject({ export async function byProject(projectId: number): Promise<IssueResponse[]> {
projectId,
onSuccess,
onError,
}: {
projectId: number;
} & ServerQueryInput) {
const url = new URL(`${getServerURL()}/issues/by-project`); const url = new URL(`${getServerURL()}/issues/by-project`);
url.searchParams.set("projectId", `${projectId}`); url.searchParams.set("projectId", `${projectId}`);
@@ -16,11 +11,9 @@ export async function byProject({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get issues by project (${res.status})`);
onError?.(error || `failed to get issues by project (${res.status})`); throw new Error(message);
} else { }
const data = await res.json();
onSuccess?.(data, res); return res.json();
}
} }

View File

@@ -1,10 +1,8 @@
import type { IssueCreateRequest, IssueRecord } from "@sprint/shared"; import type { IssueCreateRequest, IssueRecord } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function create(request: IssueCreateRequest & ServerQueryInput<IssueRecord>) { export async function create(request: IssueCreateRequest): Promise<IssueRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/create`, { const res = await fetch(`${getServerURL()}/issue/create`, {
@@ -13,23 +11,18 @@ export async function create(request: IssueCreateRequest & ServerQueryInput<Issu
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to create issue (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to create issue (${res.status})`; }
toast.error(message);
onError?.(error); const data = (await res.json()) as IssueRecord;
} else {
const data = await res.json();
if (!data.id) { if (!data.id) {
toast.error(`failed to create issue (${res.status})`); throw new Error(`failed to create issue (${res.status})`);
onError?.(`failed to create issue (${res.status})`);
return;
}
onSuccess?.(data, res);
} }
return data;
} }

View File

@@ -1,15 +1,8 @@
import type { SuccessResponse } from "@sprint/shared"; import type { SuccessResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function remove({ export async function remove(issueId: number): Promise<SuccessResponse> {
issueId,
onSuccess,
onError,
}: {
issueId: number;
} & ServerQueryInput<SuccessResponse>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/delete`, { const res = await fetch(`${getServerURL()}/issue/delete`, {
@@ -23,13 +16,9 @@ export async function remove({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to delete issue (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to delete issue (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,12 +1,8 @@
import type { IssuesReplaceStatusRequest, ReplaceStatusResponse } from "@sprint/shared"; import type { IssuesReplaceStatusRequest, ReplaceStatusResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function replaceStatus( export async function replaceStatus(request: IssuesReplaceStatusRequest): Promise<ReplaceStatusResponse> {
request: IssuesReplaceStatusRequest & ServerQueryInput<ReplaceStatusResponse>,
) {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issues/replace-status`, { const res = await fetch(`${getServerURL()}/issues/replace-status`, {
@@ -15,18 +11,14 @@ export async function replaceStatus(
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to replace status (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to replace status (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,15 +1,8 @@
import type { StatusCountResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function statusCount({ export async function statusCount(organisationId: number, status: string): Promise<StatusCountResponse> {
organisationId,
status,
onSuccess,
onError,
}: {
organisationId: number;
status: string;
} & ServerQueryInput) {
const url = new URL(`${getServerURL()}/issues/status-count`); const url = new URL(`${getServerURL()}/issues/status-count`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
url.searchParams.set("status", status); url.searchParams.set("status", status);
@@ -19,10 +12,9 @@ export async function statusCount({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get issue status count (${res.status})`);
onError?.(error || `failed to get issue status count (${res.status})`); throw new Error(message);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,25 +1,8 @@
import type { IssueRecord } from "@sprint/shared"; import type { IssueRecord, IssueUpdateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function update({ export async function update(input: IssueUpdateRequest): Promise<IssueRecord> {
issueId,
title,
description,
sprintId,
assigneeIds,
status,
onSuccess,
onError,
}: {
issueId: number;
title?: string;
description?: string;
sprintId?: number | null;
assigneeIds?: number[] | null;
status?: string;
} & ServerQueryInput<IssueRecord>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/issue/update`, { const res = await fetch(`${getServerURL()}/issue/update`, {
@@ -28,25 +11,14 @@ export async function update({
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify(input),
id: issueId,
title,
description,
sprintId,
assigneeIds,
status,
}),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update issue (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to update issue (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,10 +1,8 @@
import type { OrgAddMemberRequest, OrganisationMemberRecord } from "@sprint/shared"; import type { OrgAddMemberRequest, OrganisationMemberRecord } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function addMember(request: OrgAddMemberRequest & ServerQueryInput<OrganisationMemberRecord>) { export async function addMember(request: OrgAddMemberRequest): Promise<OrganisationMemberRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/add-member`, { const res = await fetch(`${getServerURL()}/organisation/add-member`, {
@@ -13,18 +11,14 @@ export async function addMember(request: OrgAddMemberRequest & ServerQueryInput<
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to add member (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to add member (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

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

View File

@@ -1,10 +1,8 @@
import type { OrganisationRecord, OrgCreateRequest } from "@sprint/shared"; import type { OrganisationRecord, OrgCreateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function create(request: OrgCreateRequest & ServerQueryInput<OrganisationRecord>) { export async function create(request: OrgCreateRequest): Promise<OrganisationRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/create`, { const res = await fetch(`${getServerURL()}/organisation/create`, {
@@ -13,25 +11,18 @@ export async function create(request: OrgCreateRequest & ServerQueryInput<Organi
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to create organisation (${res.status})`);
const message = throw new Error(message);
typeof error === "string" }
? error
: error.error || `failed to create organisation (${res.status})`; const data = (await res.json()) as OrganisationRecord;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
if (!data.id) { if (!data.id) {
toast.error(`failed to create organisation (${res.status})`); throw new Error(`failed to create organisation (${res.status})`);
onError?.(`failed to create organisation (${res.status})`);
return;
}
onSuccess?.(data, res);
} }
return data;
} }

View File

@@ -1,15 +1,8 @@
import type { SuccessResponse } from "@sprint/shared"; import type { SuccessResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function remove({ export async function remove(organisationId: number): Promise<SuccessResponse> {
organisationId,
onSuccess,
onError,
}: {
organisationId: number;
} & ServerQueryInput<SuccessResponse>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/delete`, { const res = await fetch(`${getServerURL()}/organisation/delete`, {
@@ -23,15 +16,9 @@ export async function remove({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to delete organisation (${res.status})`);
const message = throw new Error(message);
typeof error === "string"
? error
: error.error || `failed to delete organisation (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,14 +1,8 @@
import type { OrganisationMemberResponse } from "@sprint/shared"; import type { OrganisationMemberResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function members({ export async function members(organisationId: number): Promise<OrganisationMemberResponse[]> {
organisationId,
onSuccess,
onError,
}: {
organisationId: number;
} & ServerQueryInput<OrganisationMemberResponse[]>) {
const url = new URL(`${getServerURL()}/organisation/members`); const url = new URL(`${getServerURL()}/organisation/members`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
@@ -17,10 +11,9 @@ export async function members({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get members (${res.status})`);
onError?.(error || `failed to get members (${res.status})`); throw new Error(message);
} else {
const data = (await res.json()) as OrganisationMemberResponse[];
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,10 +1,8 @@
import type { OrgRemoveMemberRequest, SuccessResponse } from "@sprint/shared"; import type { OrgRemoveMemberRequest, SuccessResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function removeMember(request: OrgRemoveMemberRequest & ServerQueryInput<SuccessResponse>) { export async function removeMember(request: OrgRemoveMemberRequest): Promise<SuccessResponse> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/remove-member`, { const res = await fetch(`${getServerURL()}/organisation/remove-member`, {
@@ -13,18 +11,14 @@ export async function removeMember(request: OrgRemoveMemberRequest & ServerQuery
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to remove member (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to remove member (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,23 +1,8 @@
import type { OrganisationRecord } from "@sprint/shared"; import type { OrganisationRecord, OrgUpdateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function update({ export async function update(input: OrgUpdateRequest): Promise<OrganisationRecord> {
organisationId,
name,
description,
slug,
statuses,
onSuccess,
onError,
}: {
organisationId: number;
name?: string;
description?: string;
slug?: string;
statuses?: Record<string, string>;
} & ServerQueryInput<OrganisationRecord>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update`, { const res = await fetch(`${getServerURL()}/organisation/update`, {
@@ -26,26 +11,14 @@ export async function update({
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify(input),
id: organisationId,
name,
description,
slug,
statuses,
}),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update organisation (${res.status})`);
const message = throw new Error(message);
typeof error === "string"
? error
: error.error || `failed to update organisation (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,12 +1,10 @@
import type { OrganisationMemberRecord, OrgUpdateMemberRoleRequest } from "@sprint/shared"; import type { OrganisationMemberRecord, OrgUpdateMemberRoleRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function updateMemberRole( export async function updateMemberRole(
request: OrgUpdateMemberRoleRequest & ServerQueryInput<OrganisationMemberRecord>, request: OrgUpdateMemberRoleRequest,
) { ): Promise<OrganisationMemberRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/organisation/update-member-role`, { const res = await fetch(`${getServerURL()}/organisation/update-member-role`, {
@@ -15,18 +13,14 @@ export async function updateMemberRole(
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update member role (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to update member role (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,13 +1,8 @@
import type { ProjectResponse } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function byOrganisation({ export async function byOrganisation(organisationId: number): Promise<ProjectResponse[]> {
organisationId,
onSuccess,
onError,
}: {
organisationId: number;
} & ServerQueryInput) {
const url = new URL(`${getServerURL()}/projects/by-organisation`); const url = new URL(`${getServerURL()}/projects/by-organisation`);
url.searchParams.set("organisationId", `${organisationId}`); url.searchParams.set("organisationId", `${organisationId}`);
@@ -16,11 +11,9 @@ export async function byOrganisation({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get projects by organisation (${res.status})`);
onError?.(error || `failed to get projects by organisation (${res.status})`); throw new Error(message);
} else { }
const data = await res.json();
onSuccess?.(data, res); return res.json();
}
} }

View File

@@ -1,10 +1,8 @@
import type { ProjectCreateRequest, ProjectRecord } from "@sprint/shared"; import type { ProjectCreateRequest, ProjectRecord } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function create(request: ProjectCreateRequest & ServerQueryInput<ProjectRecord>) { export async function create(request: ProjectCreateRequest): Promise<ProjectRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/create`, { const res = await fetch(`${getServerURL()}/project/create`, {
@@ -13,23 +11,18 @@ export async function create(request: ProjectCreateRequest & ServerQueryInput<Pr
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to create project (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to create project (${res.status})`; }
toast.error(message);
onError?.(error); const data = (await res.json()) as ProjectRecord;
} else {
const data = await res.json();
if (!data.id) { if (!data.id) {
toast.error(`failed to create project (${res.status})`); throw new Error(`failed to create project (${res.status})`);
onError?.(`failed to create project (${res.status})`);
return;
}
onSuccess?.(data, res);
} }
return data;
} }

View File

@@ -1,15 +1,8 @@
import type { SuccessResponse } from "@sprint/shared"; import type { SuccessResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function remove({ export async function remove(projectId: number): Promise<SuccessResponse> {
projectId,
onSuccess,
onError,
}: {
projectId: number;
} & ServerQueryInput<SuccessResponse>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/delete`, { const res = await fetch(`${getServerURL()}/project/delete`, {
@@ -23,13 +16,9 @@ export async function remove({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to delete project (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to delete project (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,19 +1,8 @@
import type { ProjectRecord } from "@sprint/shared"; import type { ProjectRecord, ProjectUpdateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function update({ export async function update(input: ProjectUpdateRequest): Promise<ProjectRecord> {
projectId,
key,
name,
onSuccess,
onError,
}: {
projectId: number;
key?: string;
name?: string;
} & ServerQueryInput<ProjectRecord>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/project/update`, { const res = await fetch(`${getServerURL()}/project/update`, {
@@ -22,22 +11,14 @@ export async function update({
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify(input),
id: projectId,
key,
name,
}),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update project (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to update project (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,14 +1,8 @@
import type { SprintRecord } from "@sprint/shared"; import type { SprintRecord } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function byProject({ export async function byProject(projectId: number): Promise<SprintRecord[]> {
projectId,
onSuccess,
onError,
}: {
projectId: number;
} & ServerQueryInput<SprintRecord[]>) {
const url = new URL(`${getServerURL()}/sprints/by-project`); const url = new URL(`${getServerURL()}/sprints/by-project`);
url.searchParams.set("projectId", `${projectId}`); url.searchParams.set("projectId", `${projectId}`);
@@ -17,10 +11,9 @@ export async function byProject({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get sprints (${res.status})`);
onError?.(error || `failed to get sprints (${res.status})`); throw new Error(message);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,23 +1,8 @@
import type { SprintRecord } from "@sprint/shared"; import type { SprintCreateRequest, SprintRecord } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function create({ export async function create(input: SprintCreateRequest): Promise<SprintRecord> {
projectId,
name,
color,
startDate,
endDate,
onSuccess,
onError,
}: {
projectId: number;
name: string;
color?: string;
startDate: Date;
endDate: Date;
} & ServerQueryInput<SprintRecord>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/create`, { const res = await fetch(`${getServerURL()}/sprint/create`, {
@@ -27,28 +12,20 @@ export async function create({
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
projectId, ...input,
name: name.trim(), name: input.name.trim(),
color,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
}), }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to create sprint (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to create sprint (${res.status})`; }
toast.error(message);
onError?.(error); const data = (await res.json()) as SprintRecord;
} else {
const data = await res.json();
if (!data.id) { if (!data.id) {
toast.error(`failed to create sprint (${res.status})`); throw new Error(`failed to create sprint (${res.status})`);
onError?.(`failed to create sprint (${res.status})`);
return;
}
onSuccess?.(data, res);
} }
return data;
} }

View File

@@ -1,15 +1,8 @@
import type { SuccessResponse } from "@sprint/shared"; import type { SuccessResponse } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function remove({ export async function remove(sprintId: number): Promise<SuccessResponse> {
sprintId,
onSuccess,
onError,
}: {
sprintId: number;
} & ServerQueryInput<SuccessResponse>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/delete`, { const res = await fetch(`${getServerURL()}/sprint/delete`, {
@@ -23,13 +16,9 @@ export async function remove({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to delete sprint (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to delete sprint (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,23 +1,8 @@
import type { SprintRecord } from "@sprint/shared"; import type { SprintRecord, SprintUpdateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function update({ export async function update(input: SprintUpdateRequest): Promise<SprintRecord> {
sprintId,
name,
color,
startDate,
endDate,
onSuccess,
onError,
}: {
sprintId: number;
name?: string;
color?: string;
startDate?: Date;
endDate?: Date;
} & ServerQueryInput<SprintRecord>) {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/sprint/update`, { const res = await fetch(`${getServerURL()}/sprint/update`, {
@@ -27,23 +12,16 @@ export async function update({
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
id: sprintId, ...input,
name: name?.trim(), name: input.name?.trim(),
color,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
}), }),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update sprint (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to update sprint (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,10 +1,8 @@
import type { TimerEndRequest, TimerState } from "@sprint/shared"; import type { TimerEndRequest, TimerState } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function end(request: TimerEndRequest & ServerQueryInput<TimerState>) { export async function end(request: TimerEndRequest): Promise<TimerState> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/timer/end`, { const res = await fetch(`${getServerURL()}/timer/end`, {
@@ -13,18 +11,14 @@ export async function end(request: TimerEndRequest & ServerQueryInput<TimerState
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to end timer (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to end timer (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,14 +1,8 @@
import type { TimerState } from "@sprint/shared"; import type { TimerState } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function get({ export async function get(issueId: number): Promise<TimerState> {
issueId,
onSuccess,
onError,
}: {
issueId: number;
} & ServerQueryInput<TimerState>) {
const url = new URL(`${getServerURL()}/timer/get`); const url = new URL(`${getServerURL()}/timer/get`);
url.searchParams.set("issueId", `${issueId}`); url.searchParams.set("issueId", `${issueId}`);
@@ -17,12 +11,9 @@ export async function get({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to get timer (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to get timer (${res.status})`;
onError?.(message);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,14 +1,8 @@
import type { TimerState } from "@sprint/shared"; import type { TimerState } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function getInactive({ export async function getInactive(issueId: number): Promise<TimerState[]> {
issueId,
onSuccess,
onError,
}: {
issueId: number;
} & ServerQueryInput<TimerState[]>) {
const url = new URL(`${getServerURL()}/timer/get-inactive`); const url = new URL(`${getServerURL()}/timer/get-inactive`);
url.searchParams.set("issueId", `${issueId}`); url.searchParams.set("issueId", `${issueId}`);
@@ -17,12 +11,10 @@ export async function getInactive({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to get timers (${res.status})`;
onError?.(message);
} else {
const data = await res.json();
onSuccess?.(data || [], res);
} }
const data = (await res.json()) as TimerState[];
return data ?? [];
} }

View File

@@ -1,18 +1,15 @@
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function list({ type TimerListInput = {
limit,
offset,
onSuccess,
onError,
}: {
limit?: number; limit?: number;
offset?: number; offset?: number;
} & ServerQueryInput) { };
export async function list(input: TimerListInput = {}): Promise<unknown> {
const url = new URL(`${getServerURL()}/timers`); const url = new URL(`${getServerURL()}/timers`);
if (limit != null) url.searchParams.set("limit", `${limit}`); if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
if (offset != null) url.searchParams.set("offset", `${offset}`); if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const headers: HeadersInit = {}; const headers: HeadersInit = {};
@@ -24,10 +21,9 @@ export async function list({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const message = await getErrorMessage(res, `failed to get timers (${res.status})`);
onError?.(error || `failed to get timers (${res.status})`); throw new Error(message);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,10 +1,8 @@
import type { TimerState, TimerToggleRequest } from "@sprint/shared"; import type { TimerState, TimerToggleRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function toggle(request: TimerToggleRequest & ServerQueryInput<TimerState>) { export async function toggle(request: TimerToggleRequest): Promise<TimerState> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/timer/toggle`, { const res = await fetch(`${getServerURL()}/timer/toggle`, {
@@ -13,18 +11,14 @@ export async function toggle(request: TimerToggleRequest & ServerQueryInput<Time
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to toggle timer (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to toggle timer (${res.status})`;
toast.error(message);
onError?.(error);
} else {
const data = await res.json();
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,14 +1,8 @@
import type { UserRecord } from "@sprint/shared"; import type { UserRecord } from "@sprint/shared";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function byUsername({ export async function byUsername(username: string): Promise<UserRecord> {
username,
onSuccess,
onError,
}: {
username: string;
} & ServerQueryInput<UserRecord>) {
const url = new URL(`${getServerURL()}/user/by-username`); const url = new URL(`${getServerURL()}/user/by-username`);
url.searchParams.set("username", username); url.searchParams.set("username", username);
@@ -17,12 +11,9 @@ export async function byUsername({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to get user (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to get user (${res.status})`;
onError?.(message);
} else {
const data = (await res.json()) as UserRecord;
onSuccess?.(data, res);
} }
return res.json();
} }

View File

@@ -1,10 +1,8 @@
import type { UserRecord, UserUpdateRequest } from "@sprint/shared"; import type { UserRecord, UserUpdateRequest } from "@sprint/shared";
import { toast } from "sonner";
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function update(request: UserUpdateRequest & ServerQueryInput<UserRecord>) { export async function update(request: UserUpdateRequest): Promise<UserRecord> {
const { onSuccess, onError, ...body } = request;
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
const res = await fetch(`${getServerURL()}/user/update`, { const res = await fetch(`${getServerURL()}/user/update`, {
@@ -13,23 +11,18 @@ export async function update(request: UserUpdateRequest & ServerQueryInput<UserR
"Content-Type": "application/json", "Content-Type": "application/json",
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}), ...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
}, },
body: JSON.stringify(body), body: JSON.stringify(request),
credentials: "include", credentials: "include",
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `failed to update user (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `failed to update user (${res.status})`; }
toast.error(message);
onError?.(error); const data = (await res.json()) as UserRecord;
} else {
const data = await res.json();
if (!data.id) { if (!data.id) {
toast.error(`failed to update user (${res.status})`); throw new Error(`failed to update user (${res.status})`);
onError?.(`failed to update user (${res.status})`);
return;
}
onSuccess?.(data, res);
} }
return data;
} }

View File

@@ -1,24 +1,16 @@
import { getCsrfToken, getServerURL } from "@/lib/utils"; import { getCsrfToken, getServerURL } from "@/lib/utils";
import type { ServerQueryInput } from ".."; import { getErrorMessage } from "..";
export async function uploadAvatar({ export async function uploadAvatar(file: File): Promise<string> {
file,
onSuccess,
onError,
}: {
file: File;
} & ServerQueryInput<string>) {
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
onError?.("File size exceeds 5MB limit"); throw new Error("File size exceeds 5MB limit");
return;
} }
if (!ALLOWED_TYPES.includes(file.type)) { if (!ALLOWED_TYPES.includes(file.type)) {
onError?.("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif"); throw new Error("Invalid file type. Allowed types: png, jpg, jpeg, webp, gif");
return;
} }
const formData = new FormData(); const formData = new FormData();
@@ -36,17 +28,14 @@ export async function uploadAvatar({
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.json().catch(() => res.text()); const message = await getErrorMessage(res, `Failed to upload avatar (${res.status})`);
const message = throw new Error(message);
typeof error === "string" ? error : error.error || `Failed to upload avatar (${res.status})`;
onError?.(message);
return;
} }
const data = await res.json(); const data = await res.json();
if (data.avatarURL) { if (data.avatarURL) {
onSuccess?.(data.avatarURL, res); return data.avatarURL;
} else {
onError?.("Failed to upload avatar");
} }
throw new Error("Failed to upload avatar");
} }