Merge pull request #5 from hex248/development

ts-rest system (similar to trpc server actions)
This commit is contained in:
Oliver Bryan
2026-01-28 13:09:45 +00:00
committed by GitHub
73 changed files with 1259 additions and 1121 deletions

View File

@@ -56,6 +56,7 @@
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -86,6 +87,7 @@
"name": "@sprint/shared", "name": "@sprint/shared",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ts-rest/core": "^3.52.1",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"zod": "^3.23.8", "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=="], "@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__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=="], "@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 { asc, eq } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
@@ -23,7 +23,7 @@ export async function getIssueCommentById(id: number) {
return comment; return comment;
} }
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponse[]> { export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponseRecord[]> {
const comments = await db const comments = await db
.select({ .select({
Comment: IssueComment, 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 { aliasedTable, and, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
@@ -97,7 +97,7 @@ export async function getIssueByID(id: number) {
return issue; 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 Creator = aliasedTable(User, "Creator");
const [issueWithCreator] = await db const [issueWithCreator] = await db
@@ -212,7 +212,7 @@ export async function replaceIssueType(organisationId: number, oldType: string,
return { updated: result.rowCount ?? 0 }; 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 Creator = aliasedTable(User, "Creator");
const issuesWithCreators = await db const issuesWithCreators = await db

View File

@@ -2,6 +2,7 @@ import type { BunRequest } from "bun";
import { withAuth, withCors, withCSRF, withRateLimit } from "./auth/middleware"; import { withAuth, withCors, withCSRF, withRateLimit } from "./auth/middleware";
import { testDB } from "./db/client"; import { testDB } from "./db/client";
import { cleanupExpiredSessions } from "./db/queries"; import { cleanupExpiredSessions } from "./db/queries";
import { withAuthedLogging, withLogging } from "./logger";
import { routes } from "./routes"; import { routes } from "./routes";
const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null; const DEV = process.argv.find((arg) => ["--dev", "--developer", "-d"].includes(arg.toLowerCase())) != null;
@@ -23,7 +24,10 @@ const startSessionCleanup = () => {
type RouteHandler<T extends BunRequest = BunRequest> = (req: T) => Response | Promise<Response>; type RouteHandler<T extends BunRequest = BunRequest> = (req: T) => Response | Promise<Response>;
const withGlobal = <T extends BunRequest>(handler: RouteHandler<T>) => withCors(withRateLimit(handler)); const withGlobal = <T extends BunRequest>(handler: RouteHandler<T>) =>
withLogging(withCors(withRateLimit(handler)));
const withGlobalAuthed = <T extends BunRequest>(handler: RouteHandler<T>) =>
withAuthedLogging(withCors(withRateLimit(handler)));
const main = async () => { const main = async () => {
const server = Bun.serve({ const server = Bun.serve({
@@ -35,62 +39,64 @@ const main = async () => {
// routes that modify state require withCSRF middleware // routes that modify state require withCSRF middleware
"/auth/register": withGlobal(routes.authRegister), "/auth/register": withGlobal(routes.authRegister),
"/auth/login": withGlobal(routes.authLogin), "/auth/login": withGlobal(routes.authLogin),
"/auth/logout": withGlobal(withAuth(withCSRF(routes.authLogout))), "/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))),
"/auth/me": withGlobal(withAuth(routes.authMe)), "/auth/me": withGlobalAuthed(withAuth(routes.authMe)),
"/user/by-username": withGlobal(withAuth(routes.userByUsername)), "/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)),
"/user/update": withGlobal(withAuth(withCSRF(routes.userUpdate))), "/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))),
"/user/upload-avatar": withGlobal(routes.userUploadAvatar), "/user/upload-avatar": withGlobalAuthed(withAuth(routes.userUploadAvatar)),
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))), "/issue/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCreate))),
"/issue/by-id": withGlobal(withAuth(routes.issueById)), "/issue/by-id": withGlobalAuthed(withAuth(routes.issueById)),
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))), "/issue/update": withGlobalAuthed(withAuth(withCSRF(routes.issueUpdate))),
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))), "/issue/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueDelete))),
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))), "/issue-comment/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentCreate))),
"/issue-comment/delete": withGlobal(withAuth(withCSRF(routes.issueCommentDelete))), "/issue-comment/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentDelete))),
"/issues/by-project": withGlobal(withAuth(routes.issuesByProject)), "/issues/by-project": withGlobalAuthed(withAuth(routes.issuesByProject)),
"/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))), "/issues/replace-status": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceStatus))),
"/issues/replace-type": withGlobal(withAuth(withCSRF(routes.issuesReplaceType))), "/issues/replace-type": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceType))),
"/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)), "/issues/status-count": withGlobalAuthed(withAuth(routes.issuesStatusCount)),
"/issues/type-count": withGlobal(withAuth(routes.issuesTypeCount)), "/issues/type-count": withGlobalAuthed(withAuth(routes.issuesTypeCount)),
"/issues/all": withGlobal(withAuth(routes.issues)), "/issues/all": withGlobalAuthed(withAuth(routes.issues)),
"/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)), "/issue-comments/by-issue": withGlobalAuthed(withAuth(routes.issueCommentsByIssue)),
"/organisation/create": withGlobal(withAuth(withCSRF(routes.organisationCreate))), "/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))),
"/organisation/by-id": withGlobal(withAuth(routes.organisationById)), "/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)),
"/organisation/update": withGlobal(withAuth(withCSRF(routes.organisationUpdate))), "/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))),
"/organisation/delete": withGlobal(withAuth(withCSRF(routes.organisationDelete))), "/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))),
"/organisation/upload-icon": withGlobal(withAuth(withCSRF(routes.organisationUploadIcon))), "/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
"/organisation/add-member": withGlobal(withAuth(withCSRF(routes.organisationAddMember))), "/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))),
"/organisation/members": withGlobal(withAuth(routes.organisationMembers)), "/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)),
"/organisation/remove-member": withGlobal(withAuth(withCSRF(routes.organisationRemoveMember))), "/organisation/remove-member": withGlobalAuthed(
"/organisation/update-member-role": withGlobal( withAuth(withCSRF(routes.organisationRemoveMember)),
),
"/organisation/update-member-role": withGlobalAuthed(
withAuth(withCSRF(routes.organisationUpdateMemberRole)), withAuth(withCSRF(routes.organisationUpdateMemberRole)),
), ),
"/organisations/by-user": withGlobal(withAuth(routes.organisationsByUser)), "/organisations/by-user": withGlobalAuthed(withAuth(routes.organisationsByUser)),
"/project/create": withGlobal(withAuth(withCSRF(routes.projectCreate))), "/project/create": withGlobalAuthed(withAuth(withCSRF(routes.projectCreate))),
"/project/update": withGlobal(withAuth(withCSRF(routes.projectUpdate))), "/project/update": withGlobalAuthed(withAuth(withCSRF(routes.projectUpdate))),
"/project/delete": withGlobal(withAuth(withCSRF(routes.projectDelete))), "/project/delete": withGlobalAuthed(withAuth(withCSRF(routes.projectDelete))),
"/project/with-creator": withGlobal(withAuth(routes.projectWithCreator)), "/project/with-creator": withGlobalAuthed(withAuth(routes.projectWithCreator)),
"/projects/by-creator": withGlobal(withAuth(routes.projectsByCreator)), "/projects/by-creator": withGlobalAuthed(withAuth(routes.projectsByCreator)),
"/projects/by-organisation": withGlobal(withAuth(routes.projectsByOrganisation)), "/projects/by-organisation": withGlobalAuthed(withAuth(routes.projectsByOrganisation)),
"/projects/all": withGlobal(withAuth(routes.projectsAll)), "/projects/all": withGlobalAuthed(withAuth(routes.projectsAll)),
"/projects/with-creators": withGlobal(withAuth(routes.projectsWithCreators)), "/projects/with-creators": withGlobalAuthed(withAuth(routes.projectsWithCreators)),
"/sprint/create": withGlobal(withAuth(withCSRF(routes.sprintCreate))), "/sprint/create": withGlobalAuthed(withAuth(withCSRF(routes.sprintCreate))),
"/sprint/update": withGlobal(withAuth(withCSRF(routes.sprintUpdate))), "/sprint/update": withGlobalAuthed(withAuth(withCSRF(routes.sprintUpdate))),
"/sprint/delete": withGlobal(withAuth(withCSRF(routes.sprintDelete))), "/sprint/delete": withGlobalAuthed(withAuth(withCSRF(routes.sprintDelete))),
"/sprints/by-project": withGlobal(withAuth(routes.sprintsByProject)), "/sprints/by-project": withGlobalAuthed(withAuth(routes.sprintsByProject)),
"/timer/toggle": withGlobal(withAuth(withCSRF(routes.timerToggle))), "/timer/toggle": withGlobalAuthed(withAuth(withCSRF(routes.timerToggle))),
"/timer/end": withGlobal(withAuth(withCSRF(routes.timerEnd))), "/timer/end": withGlobalAuthed(withAuth(withCSRF(routes.timerEnd))),
"/timer/get": withGlobal(withAuth(withCSRF(routes.timerGet))), "/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))),
"/timer/get-inactive": withGlobal(withAuth(withCSRF(routes.timerGetInactive))), "/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))),
"/timers": withGlobal(withAuth(withCSRF(routes.timers))), "/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))),
}, },
}); });

View File

@@ -0,0 +1,165 @@
import type { BunRequest } from "bun";
type LogLevel = "info" | "warn" | "error";
interface RequestLog {
timestamp: Date;
level: LogLevel;
method: string;
url: string;
status: number;
duration: number;
userId?: number;
}
const LOG_LEVEL: LogLevel = (process.env.LOG_LEVEL as LogLevel) ?? "info";
const shouldLog = (level: LogLevel): boolean => {
const levels: LogLevel[] = ["info", "warn", "error"];
return levels.indexOf(level) >= levels.indexOf(LOG_LEVEL);
};
const COLORS = {
reset: "\x1b[0m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
magenta: "\x1b[35m",
};
const getStatusColor = (status: number): string => {
if (status >= 500) return COLORS.red;
if (status >= 400) return COLORS.yellow;
if (status >= 300) return COLORS.cyan;
return COLORS.green;
};
const getMethodColor = (method: string): string => {
switch (method) {
case "GET":
return COLORS.green;
case "POST":
return COLORS.cyan;
case "PUT":
case "PATCH":
return COLORS.yellow;
case "DELETE":
return COLORS.red;
default:
return COLORS.reset;
}
};
const formatTimestamp = (date: Date): string => {
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const year = String(date.getFullYear()).slice(-2);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${day}-${month}-${year} ${hours}:${minutes}:${seconds}`;
};
const formatLog = (log: RequestLog): string => {
const timestamp = `${COLORS.dim}${formatTimestamp(log.timestamp)}${COLORS.reset}`;
const method = `${getMethodColor(log.method)}${log.method}${COLORS.reset}`;
const url = log.url;
const status = `${getStatusColor(log.status)}${log.status}${COLORS.reset}`;
const duration = `${COLORS.dim}${log.duration}ms${COLORS.reset}`;
const user = log.userId ? ` ${COLORS.magenta}user:${log.userId}${COLORS.reset}` : "";
return `${timestamp} ${method} ${url} ${status} ${duration}${user}`;
};
const writeLog = (log: RequestLog) => {
if (!shouldLog(log.level)) return;
console.log(formatLog(log));
};
const getLogLevel = (status: number): LogLevel => {
if (status >= 500) return "error";
if (status >= 400) return "warn";
return "info";
};
type RouteHandler<T extends BunRequest = BunRequest> = (req: T) => Response | Promise<Response>;
export const withLogging = <T extends BunRequest>(handler: RouteHandler<T>): RouteHandler<T> => {
return async (req: T) => {
const start = performance.now();
const url = new URL(req.url);
try {
const res = await handler(req);
const duration = Math.round(performance.now() - start);
const log: RequestLog = {
timestamp: new Date(),
level: getLogLevel(res.status),
method: req.method,
url: url.pathname,
status: res.status,
duration,
};
writeLog(log);
return res;
} catch (error) {
const duration = Math.round(performance.now() - start);
const log: RequestLog = {
timestamp: new Date(),
level: "error",
method: req.method,
url: url.pathname,
status: 500,
duration,
};
writeLog(log);
throw error;
}
};
};
export const withAuthedLogging = <T extends BunRequest>(handler: RouteHandler<T>): RouteHandler<T> => {
return async (req: T) => {
const start = performance.now();
const url = new URL(req.url);
try {
const res = await handler(req);
const duration = Math.round(performance.now() - start);
const log: RequestLog = {
timestamp: new Date(),
level: getLogLevel(res.status),
method: req.method,
url: url.pathname,
status: res.status,
duration,
userId: (req as { userId?: number }).userId,
};
writeLog(log);
return res;
} catch (error) {
const duration = Math.round(performance.now() - start);
const log: RequestLog = {
timestamp: new Date(),
level: "error",
method: req.method,
url: url.pathname,
status: 500,
duration,
userId: (req as { userId?: number }).userId,
};
writeLog(log);
throw error;
}
};
};

View File

@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@ts-rest/core": "^3.52.1",
"@nsmr/pixelart-react": "^2.0.0", "@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15", "@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 { type FormEvent, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -12,7 +12,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { useAddOrganisationMember } from "@/lib/query/hooks"; import { useAddOrganisationMember } from "@/lib/query/hooks";
import { parseError, user } from "@/lib/server"; import { apiClient, parseError } from "@/lib/server";
export function AddMember({ export function AddMember({
organisationId, organisationId,
@@ -23,7 +23,7 @@ export function AddMember({
organisationId: number; organisationId: number;
existingMembers: string[]; existingMembers: string[];
trigger?: React.ReactNode; trigger?: React.ReactNode;
onSuccess?: (user: UserRecord) => void | Promise<void>; onSuccess?: (user: UserResponse) => void | Promise<void>;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -62,7 +62,10 @@ export function AddMember({
setSubmitting(true); setSubmitting(true);
try { 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; const userId = userData.id;
await addMember.mutateAsync({ organisationId, userId, role: "member" }); await addMember.mutateAsync({ organisationId, userId, role: "member" });
setOpen(false); setOpen(false);

View File

@@ -34,10 +34,8 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
const sortedComments = useMemo(() => { const sortedComments = useMemo(() => {
return [...data].sort((a, b) => { return [...data].sort((a, b) => {
const aDate = const aDate = a.Comment.createdAt ? new Date(a.Comment.createdAt) : new Date(0);
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0); const bDate = b.Comment.createdAt ? new Date(b.Comment.createdAt) : new Date(0);
const bDate =
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
return aDate.getTime() - bDate.getTime(); return aDate.getTime() - bDate.getTime();
}); });
}, [data]); }, [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 { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { IssueComments } from "@/components/issue-comments"; import { IssueComments } from "@/components/issue-comments";
@@ -51,7 +51,7 @@ export function IssueDetails({
issueData: IssueResponse; issueData: IssueResponse;
projectKey: string; projectKey: string;
sprints: SprintRecord[]; sprints: SprintRecord[];
members: UserRecord[]; members: UserResponse[];
statuses: Record<string, string>; statuses: Record<string, string>;
onClose: () => void; onClose: () => void;
onDelete?: () => 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 Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { UserSelect } from "@/components/user-select"; import { UserSelect } from "@/components/user-select";
@@ -9,10 +9,10 @@ export function MultiAssigneeSelect({
onChange, onChange,
fallbackUsers = [], fallbackUsers = [],
}: { }: {
users: UserRecord[]; users: UserResponse[];
assigneeIds: string[]; assigneeIds: string[];
onChange: (assigneeIds: string[]) => void; onChange: (assigneeIds: string[]) => void;
fallbackUsers?: UserRecord[]; fallbackUsers?: UserResponse[];
}) { }) {
const handleAssigneeChange = (index: number, value: string) => { const handleAssigneeChange = (index: number, value: string) => {
// if set to "unassigned" and there are other rows, remove this row // if set to "unassigned" and there are other rows, remove this row

View File

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

View File

@@ -51,7 +51,7 @@ import {
useUpdateOrganisationMemberRole, useUpdateOrganisationMemberRole,
} from "@/lib/query/hooks"; } from "@/lib/query/hooks";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server"; import { apiClient } from "@/lib/server";
import { capitalise, unCamelCase } from "@/lib/utils"; import { capitalise, unCamelCase } from "@/lib/utils";
import { Switch } from "./ui/switch"; import { Switch } from "./ui/switch";
@@ -370,8 +370,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const handleRemoveStatusClick = async (status: string) => { const handleRemoveStatusClick = async (status: string) => {
if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return; if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return;
try { try {
const data = await issue.statusCount(selectedOrganisation.Organisation.id, status); const { data, error } = await apiClient.issuesStatusCount({
const count = data.find((item) => item.status === status)?.count ?? 0; 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) { if (count > 0) {
setStatusToRemove(status); setStatusToRemove(status);
setIssuesUsingStatus(count); setIssuesUsingStatus(count);
@@ -546,8 +550,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const handleRemoveTypeClick = async (typeName: string) => { const handleRemoveTypeClick = async (typeName: string) => {
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return; if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
try { try {
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName); const { data, error } = await apiClient.issuesTypeCount({
const count = data.count ?? 0; 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) { if (count > 0) {
setTypeToRemove(typeName); setTypeToRemove(typeName);
setIssuesUsingType(count); 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 { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import Loading from "@/components/loading"; import Loading from "@/components/loading";
@@ -6,8 +6,8 @@ import { LoginModal } from "@/components/login-modal";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue { interface SessionContextValue {
user: UserRecord | null; user: UserResponse | null;
setUser: (user: UserRecord) => void; setUser: (user: UserResponse) => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -28,7 +28,7 @@ export function useSessionSafe(): SessionContextValue | null {
} }
// for use inside RequireAuth // 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(); const { user, setUser } = useSession();
if (!user) { if (!user) {
throw new Error("useAuthenticatedSession must be used within RequireAuth"); 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 }) { 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 [isLoading, setIsLoading] = useState(true);
const fetched = useRef(false); const fetched = useRef(false);
const setUser = useCallback((user: UserRecord) => { const setUser = useCallback((user: UserResponse) => {
setUserState(user); setUserState(user);
localStorage.setItem("user", JSON.stringify(user)); localStorage.setItem("user", JSON.stringify(user));
}, []); }, []);
@@ -57,7 +57,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
if (!res.ok) { if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`); 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); setUser(data.user);
setCsrfToken(data.csrfToken); 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 Avatar from "@/components/avatar";
import { cn } from "@/lib/utils"; 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 ( return (
<div className={cn("flex gap-2 items-center", className)}> <div className={cn("flex gap-2 items-center", className)}>
<Avatar <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 { useState } from "react";
import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallSprintDisplay from "@/components/small-sprint-display";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -12,7 +12,6 @@ export function SprintSelect({
sprints: SprintRecord[]; sprints: SprintRecord[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
fallbackUser?: UserRecord | null;
placeholder?: string; placeholder?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); 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 { useState } from "react";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -10,10 +10,10 @@ export function UserSelect({
fallbackUser, fallbackUser,
placeholder = "Select user", placeholder = "Select user",
}: { }: {
users: UserRecord[]; users: UserResponse[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
fallbackUser?: UserRecord | null; fallbackUser?: UserResponse | null;
placeholder?: string; placeholder?: string;
}) { }) {
const [isOpen, setIsOpen] = useState(false); 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"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { issueComment } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useIssueComments(issueId?: number | null) { export function useIssueComments(issueId?: number | null) {
return useQuery<IssueCommentResponse[]>({ return useQuery<IssueCommentResponse[]>({
queryKey: queryKeys.issueComments.byIssue(issueId ?? 0), 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), enabled: Boolean(issueId),
}); });
} }
@@ -22,7 +28,12 @@ export function useCreateIssueComment() {
return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({ return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({
mutationKey: ["issue-comments", "create"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.issueComments.byIssue(variables.issueId), queryKey: queryKeys.issueComments.byIssue(variables.issueId),
@@ -36,7 +47,12 @@ export function useDeleteIssueComment() {
return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({ return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({
mutationKey: ["issue-comments", "delete"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all });
}, },

View File

@@ -12,12 +12,18 @@ import type {
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useIssues(projectId?: number | null) { export function useIssues(projectId?: number | null) {
return useQuery<IssueResponse[]>({ return useQuery<IssueResponse[]>({
queryKey: queryKeys.issues.byProject(projectId ?? 0), 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), enabled: Boolean(projectId),
}); });
} }
@@ -25,7 +31,14 @@ export function useIssues(projectId?: number | null) {
export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) { export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
return useQuery<IssueResponse>({ return useQuery<IssueResponse>({
queryKey: queryKeys.issues.byId(issueId ?? 0), 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), enabled: Boolean(issueId),
}); });
} }
@@ -33,7 +46,13 @@ export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) { export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({ return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""), 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), enabled: Boolean(organisationId && status),
}); });
} }
@@ -43,7 +62,12 @@ export function useCreateIssue() {
return useMutation<IssueRecord, Error, IssueCreateRequest>({ return useMutation<IssueRecord, Error, IssueCreateRequest>({
mutationKey: ["issues", "create"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.issues.byProject(variables.projectId), queryKey: queryKeys.issues.byProject(variables.projectId),
@@ -57,7 +81,12 @@ export function useUpdateIssue() {
return useMutation<IssueRecord, Error, IssueUpdateRequest>({ return useMutation<IssueRecord, Error, IssueUpdateRequest>({
mutationKey: ["issues", "update"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
@@ -69,7 +98,12 @@ export function useDeleteIssue() {
return useMutation<SuccessResponse, Error, number>({ return useMutation<SuccessResponse, Error, number>({
mutationKey: ["issues", "delete"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
@@ -81,7 +115,11 @@ export function useReplaceIssueStatus() {
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({ return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
mutationKey: ["issues", "replace-status"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },
@@ -91,7 +129,14 @@ export function useReplaceIssueStatus() {
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) { export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
return useQuery<TypeCountResponse>({ return useQuery<TypeCountResponse>({
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""), 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), enabled: Boolean(organisationId && type),
}); });
} }
@@ -101,7 +146,11 @@ export function useReplaceIssueType() {
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({ return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
mutationKey: ["issues", "replace-type"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
}, },

View File

@@ -2,22 +2,39 @@ import type {
OrgAddMemberRequest, OrgAddMemberRequest,
OrganisationMemberRecord, OrganisationMemberRecord,
OrganisationMemberResponse, OrganisationMemberResponse,
OrganisationRecordType,
OrganisationResponse,
OrgCreateRequest,
OrgRemoveMemberRequest,
OrgUpdateMemberRoleRequest,
OrgUpdateRequest,
SuccessResponse,
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { organisation } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useOrganisations() { export function useOrganisations() {
return useQuery({ return useQuery<OrganisationResponse[]>({
queryKey: queryKeys.organisations.byUser(), 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) { export function useOrganisationMembers(organisationId?: number | null) {
return useQuery<OrganisationMemberResponse[]>({ return useQuery<OrganisationMemberResponse[]>({
queryKey: queryKeys.organisations.members(organisationId ?? 0), 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), enabled: Boolean(organisationId),
}); });
} }
@@ -25,9 +42,14 @@ export function useOrganisationMembers(organisationId?: number | null) {
export function useCreateOrganisation() { export function useCreateOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<OrganisationRecordType, Error, OrgCreateRequest>({
mutationKey: ["organisations", "create"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
@@ -37,9 +59,14 @@ export function useCreateOrganisation() {
export function useUpdateOrganisation() { export function useUpdateOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<OrganisationRecordType, Error, OrgUpdateRequest>({
mutationKey: ["organisations", "update"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
@@ -49,9 +76,14 @@ export function useUpdateOrganisation() {
export function useDeleteOrganisation() { export function useDeleteOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<SuccessResponse, Error, number>({
mutationKey: ["organisations", "delete"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },
@@ -63,7 +95,12 @@ export function useAddOrganisationMember() {
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({ return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
mutationKey: ["organisations", "members", "add"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -75,9 +112,14 @@ export function useAddOrganisationMember() {
export function useRemoveOrganisationMember() { export function useRemoveOrganisationMember() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<SuccessResponse, Error, OrgRemoveMemberRequest>({
mutationKey: ["organisations", "members", "remove"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -89,9 +131,14 @@ export function useRemoveOrganisationMember() {
export function useUpdateOrganisationMemberRole() { export function useUpdateOrganisationMemberRole() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<OrganisationMemberRecord, Error, OrgUpdateMemberRoleRequest>({
mutationKey: ["organisations", "members", "update-role"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId), queryKey: queryKeys.organisations.members(variables.organisationId),
@@ -105,7 +152,15 @@ export function useUploadOrganisationIcon() {
return useMutation<string, Error, { file: File; organisationId: number }>({ return useMutation<string, Error, { file: File; organisationId: number }>({
mutationKey: ["organisations", "upload-icon"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
}, },

View File

@@ -3,15 +3,22 @@ import type {
ProjectRecord, ProjectRecord,
ProjectResponse, ProjectResponse,
ProjectUpdateRequest, ProjectUpdateRequest,
SuccessResponse,
} from "@sprint/shared"; } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { project } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useProjects(organisationId?: number | null) { export function useProjects(organisationId?: number | null) {
return useQuery<ProjectResponse[]>({ return useQuery<ProjectResponse[]>({
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0), 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), enabled: Boolean(organisationId),
}); });
} }
@@ -21,7 +28,12 @@ export function useCreateProject() {
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({ return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
mutationKey: ["projects", "create"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.projects.byOrganisation(variables.organisationId), queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
@@ -35,7 +47,12 @@ export function useUpdateProject() {
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({ return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
mutationKey: ["projects", "update"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
}, },
@@ -45,9 +62,14 @@ export function useUpdateProject() {
export function useDeleteProject() { export function useDeleteProject() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<SuccessResponse, Error, number>({
mutationKey: ["projects", "delete"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { sprint } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useSprints(projectId?: number | null) { export function useSprints(projectId?: number | null) {
return useQuery<SprintRecord[]>({ return useQuery<SprintRecord[]>({
queryKey: queryKeys.sprints.byProject(projectId ?? 0), 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), enabled: Boolean(projectId),
}); });
} }
@@ -16,7 +22,12 @@ export function useCreateSprint() {
return useMutation<SprintRecord, Error, SprintCreateRequest>({ return useMutation<SprintRecord, Error, SprintCreateRequest>({
mutationKey: ["sprints", "create"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
}, },
@@ -28,7 +39,12 @@ export function useUpdateSprint() {
return useMutation<SprintRecord, Error, SprintUpdateRequest>({ return useMutation<SprintRecord, Error, SprintUpdateRequest>({
mutationKey: ["sprints", "update"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
}, },
@@ -38,9 +54,14 @@ export function useUpdateSprint() {
export function useDeleteSprint() { export function useDeleteSprint() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation<SuccessResponse, Error, number>({
mutationKey: ["sprints", "delete"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
}, },

View File

@@ -1,9 +1,13 @@
import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared"; import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; 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 }) { export function useActiveTimers(options?: { refetchInterval?: number; enabled?: boolean }) {
return useQuery<TimerListItem[]>({ return useQuery<TimerListItem[]>({
@@ -29,7 +33,13 @@ export function useTimerState(issueId?: number | null, options?: { refetchInterv
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) { export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState[]>({ return useQuery<TimerState[]>({
queryKey: queryKeys.timers.inactive(issueId ?? 0), 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), enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval, refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
@@ -41,7 +51,12 @@ export function useToggleTimer() {
return useMutation<TimerState, Error, TimerToggleRequest>({ return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
@@ -54,7 +69,12 @@ export function useEndTimer() {
return useMutation<TimerState, Error, TimerEndRequest>({ return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"], 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) => { onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) }); queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() }); 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { user } from "@/lib/server"; import { apiClient } from "@/lib/server";
export function useUserByUsername(username?: string | null) { export function useUserByUsername(username?: string | null) {
return useQuery<UserRecord>({ return useQuery<UserResponse>({
queryKey: queryKeys.users.byUsername(username ?? ""), 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), enabled: Boolean(username),
}); });
} }
@@ -14,9 +21,14 @@ export function useUserByUsername(username?: string | null) {
export function useUpdateUser() { export function useUpdateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<UserRecord, Error, UserUpdateRequest>({ return useMutation<UserResponse, Error, UserUpdateRequest>({
mutationKey: ["users", "update"], 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) => { onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
}, },
@@ -28,7 +40,14 @@ export function useUploadAvatar() {
return useMutation<string, Error, File>({ return useMutation<string, Error, File>({
mutationKey: ["users", "upload-avatar"], 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
}, },

View File

@@ -1,29 +1,6 @@
import type { ApiError } from "@sprint/shared"; import type { ApiError } from "@sprint/shared";
export * as issue from "@/lib/server/issue"; export { apiClient } from "@/lib/api-client";
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 function parseError(error: ApiError | string | Error): string { export function parseError(error: ApiError | string | Error): string {
if (typeof error === "string") return error; 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" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"@ts-rest/core": "^3.52.1",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -57,6 +57,7 @@ export const AuthResponseSchema = z.object({
name: z.string(), name: z.string(),
username: z.string(), username: z.string(),
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
}), }),
csrfToken: z.string(), csrfToken: z.string(),
}); });
@@ -410,6 +411,7 @@ export const UserResponseSchema = z.object({
name: z.string(), name: z.string(),
username: z.string(), username: z.string(),
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(),
}); });
@@ -434,7 +436,7 @@ export const IssueResponseSchema = z.object({
Assignees: z.array(UserResponseSchema), Assignees: z.array(UserResponseSchema),
}); });
export type IssueResponseType = z.infer<typeof IssueResponseSchema>; export type IssueResponse = z.infer<typeof IssueResponseSchema>;
export const IssueCommentRecordSchema = z.object({ export const IssueCommentRecordSchema = z.object({
id: z.number(), id: z.number(),
@@ -450,18 +452,23 @@ export const IssueCommentResponseSchema = z.object({
User: UserResponseSchema, User: UserResponseSchema,
}); });
export type IssueCommentResponseType = z.infer<typeof IssueCommentResponseSchema>; export type IssueCommentResponse = z.infer<typeof IssueCommentResponseSchema>;
export const OrganisationRecordSchema = z.object({ export const OrganisationRecordSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
slug: z.string(), slug: z.string(),
iconURL: z.string().nullable().optional(),
statuses: z.record(z.string()), 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(), createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(),
}); });
export type OrganisationRecordType = z.infer<typeof OrganisationRecordSchema>;
export const OrganisationMemberRecordSchema = z.object({ export const OrganisationMemberRecordSchema = z.object({
id: z.number(), id: z.number(),
organisationId: z.number(), organisationId: z.number(),
@@ -470,12 +477,22 @@ export const OrganisationMemberRecordSchema = z.object({
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
}); });
export type OrganisationMemberRecordType = z.infer<typeof OrganisationMemberRecordSchema>;
export const OrganisationResponseSchema = z.object({ export const OrganisationResponseSchema = z.object({
Organisation: OrganisationRecordSchema, Organisation: OrganisationRecordSchema,
OrganisationMember: OrganisationMemberRecordSchema, 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({ export const ProjectRecordSchema = z.object({
id: z.number(), id: z.number(),
@@ -491,7 +508,14 @@ export const ProjectResponseSchema = z.object({
User: UserResponseSchema, 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({ export const SprintRecordSchema = z.object({
id: z.number(), id: z.number(),
@@ -503,7 +527,7 @@ export const SprintRecordSchema = z.object({
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
}); });
export type SprintResponseType = z.infer<typeof SprintRecordSchema>; export type SprintResponse = z.infer<typeof SprintRecordSchema>;
export const TimerStateSchema = z export const TimerStateSchema = z
.object({ .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, IssueByIdQuery,
IssueCommentCreateRequest, IssueCommentCreateRequest,
IssueCommentDeleteRequest, IssueCommentDeleteRequest,
IssueCommentResponseType, IssueCommentResponse,
IssueCommentsByIssueQuery, IssueCommentsByIssueQuery,
IssueCreateRequest, IssueCreateRequest,
IssueDeleteRequest, IssueDeleteRequest,
IssueResponseType, IssueResponse,
IssuesByProjectQuery, IssuesByProjectQuery,
IssuesReplaceStatusRequest, IssuesReplaceStatusRequest,
IssuesReplaceTypeRequest, IssuesReplaceTypeRequest,
@@ -17,7 +17,10 @@ export type {
IssueUpdateRequest, IssueUpdateRequest,
LoginRequest, LoginRequest,
OrgAddMemberRequest, OrgAddMemberRequest,
OrganisationResponseType, OrganisationMemberRecordType,
OrganisationMemberResponse,
OrganisationRecordType,
OrganisationResponse,
OrgByIdQuery, OrgByIdQuery,
OrgCreateRequest, OrgCreateRequest,
OrgDeleteRequest, OrgDeleteRequest,
@@ -30,14 +33,15 @@ export type {
ProjectByOrgQuery, ProjectByOrgQuery,
ProjectCreateRequest, ProjectCreateRequest,
ProjectDeleteRequest, ProjectDeleteRequest,
ProjectResponseType, ProjectResponse,
ProjectUpdateRequest, ProjectUpdateRequest,
ProjectWithCreatorResponse,
RegisterRequest, RegisterRequest,
ReplaceStatusResponse, ReplaceStatusResponse,
ReplaceTypeResponse, ReplaceTypeResponse,
SprintCreateRequest, SprintCreateRequest,
SprintDeleteRequest, SprintDeleteRequest,
SprintResponseType, SprintResponse,
SprintsByProjectQuery, SprintsByProjectQuery,
SprintUpdateRequest, SprintUpdateRequest,
StatusCountResponse, StatusCountResponse,
@@ -76,6 +80,7 @@ export {
LoginRequestSchema, LoginRequestSchema,
OrgAddMemberRequestSchema, OrgAddMemberRequestSchema,
OrganisationMemberRecordSchema, OrganisationMemberRecordSchema,
OrganisationMemberResponseSchema,
OrganisationRecordSchema, OrganisationRecordSchema,
OrganisationResponseSchema, OrganisationResponseSchema,
OrgByIdQuerySchema, OrgByIdQuerySchema,
@@ -93,6 +98,7 @@ export {
ProjectRecordSchema, ProjectRecordSchema,
ProjectResponseSchema, ProjectResponseSchema,
ProjectUpdateRequestSchema, ProjectUpdateRequestSchema,
ProjectWithCreatorResponseSchema,
RegisterRequestSchema, RegisterRequestSchema,
ReplaceStatusResponseSchema, ReplaceStatusResponseSchema,
ReplaceTypeResponseSchema, ReplaceTypeResponseSchema,
@@ -129,25 +135,27 @@ export {
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
export type { ApiContract } from "./contract";
export { apiContract } from "./contract";
export type { export type {
IconStyle, IconStyle,
IssueAssigneeInsert, IssueAssigneeInsert,
IssueAssigneeRecord, IssueAssigneeRecord,
IssueCommentInsert, IssueCommentInsert,
IssueCommentRecord, IssueCommentRecord,
IssueCommentResponse, IssueCommentResponse as IssueCommentResponseRecord,
IssueInsert, IssueInsert,
IssueRecord, IssueRecord,
IssueResponse, IssueResponse as IssueResponseRecord,
OrganisationInsert, OrganisationInsert,
OrganisationMemberInsert, OrganisationMemberInsert,
OrganisationMemberRecord, OrganisationMemberRecord,
OrganisationMemberResponse, OrganisationMemberResponse as OrganisationMemberResponseRecord,
OrganisationRecord, OrganisationRecord,
OrganisationResponse, OrganisationResponse as OrganisationResponseRecord,
ProjectInsert, ProjectInsert,
ProjectRecord, ProjectRecord,
ProjectResponse, ProjectResponse as ProjectResponseRecord,
SessionInsert, SessionInsert,
SessionRecord, SessionRecord,
SprintInsert, SprintInsert,