mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
Merge pull request #5 from hex248/development
ts-rest system (similar to trpc server actions)
This commit is contained in:
4
bun.lock
4
bun.lock
@@ -56,6 +56,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.91.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -86,6 +87,7 @@
|
||||
"name": "@sprint/shared",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"zod": "^3.23.8",
|
||||
@@ -468,6 +470,8 @@
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
||||
|
||||
"@ts-rest/core": ["@ts-rest/core@3.52.1", "", { "peerDependencies": { "@types/node": "^18.18.7 || >=20.8.4", "zod": "^3.22.3" }, "optionalPeers": ["@types/node", "zod"] }, "sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Issue, IssueComment, type IssueCommentResponse, Project, User } from "@sprint/shared";
|
||||
import { Issue, IssueComment, type IssueCommentResponseRecord, Project, User } from "@sprint/shared";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function getIssueCommentById(id: number) {
|
||||
return comment;
|
||||
}
|
||||
|
||||
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponse[]> {
|
||||
export async function getIssueCommentsByIssueId(issueId: number): Promise<IssueCommentResponseRecord[]> {
|
||||
const comments = await db
|
||||
.select({
|
||||
Comment: IssueComment,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Issue, IssueAssignee, type IssueResponse, User, type UserRecord } from "@sprint/shared";
|
||||
import { Issue, IssueAssignee, type IssueResponseRecord, User, type UserRecord } from "@sprint/shared";
|
||||
import { aliasedTable, and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../client";
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function getIssueByID(id: number) {
|
||||
return issue;
|
||||
}
|
||||
|
||||
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponse | null> {
|
||||
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponseRecord | null> {
|
||||
const Creator = aliasedTable(User, "Creator");
|
||||
|
||||
const [issueWithCreator] = await db
|
||||
@@ -212,7 +212,7 @@ export async function replaceIssueType(organisationId: number, oldType: string,
|
||||
return { updated: result.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponse[]> {
|
||||
export async function getIssuesWithUsersByProject(projectId: number): Promise<IssueResponseRecord[]> {
|
||||
const Creator = aliasedTable(User, "Creator");
|
||||
|
||||
const issuesWithCreators = await db
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BunRequest } from "bun";
|
||||
import { withAuth, withCors, withCSRF, withRateLimit } from "./auth/middleware";
|
||||
import { testDB } from "./db/client";
|
||||
import { cleanupExpiredSessions } from "./db/queries";
|
||||
import { withAuthedLogging, withLogging } from "./logger";
|
||||
import { routes } from "./routes";
|
||||
|
||||
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>;
|
||||
|
||||
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 server = Bun.serve({
|
||||
@@ -35,62 +39,64 @@ const main = async () => {
|
||||
// routes that modify state require withCSRF middleware
|
||||
"/auth/register": withGlobal(routes.authRegister),
|
||||
"/auth/login": withGlobal(routes.authLogin),
|
||||
"/auth/logout": withGlobal(withAuth(withCSRF(routes.authLogout))),
|
||||
"/auth/me": withGlobal(withAuth(routes.authMe)),
|
||||
"/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))),
|
||||
"/auth/me": withGlobalAuthed(withAuth(routes.authMe)),
|
||||
|
||||
"/user/by-username": withGlobal(withAuth(routes.userByUsername)),
|
||||
"/user/update": withGlobal(withAuth(withCSRF(routes.userUpdate))),
|
||||
"/user/upload-avatar": withGlobal(routes.userUploadAvatar),
|
||||
"/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)),
|
||||
"/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))),
|
||||
"/user/upload-avatar": withGlobalAuthed(withAuth(routes.userUploadAvatar)),
|
||||
|
||||
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
|
||||
"/issue/by-id": withGlobal(withAuth(routes.issueById)),
|
||||
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))),
|
||||
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))),
|
||||
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))),
|
||||
"/issue-comment/delete": withGlobal(withAuth(withCSRF(routes.issueCommentDelete))),
|
||||
"/issue/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCreate))),
|
||||
"/issue/by-id": withGlobalAuthed(withAuth(routes.issueById)),
|
||||
"/issue/update": withGlobalAuthed(withAuth(withCSRF(routes.issueUpdate))),
|
||||
"/issue/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueDelete))),
|
||||
"/issue-comment/create": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentCreate))),
|
||||
"/issue-comment/delete": withGlobalAuthed(withAuth(withCSRF(routes.issueCommentDelete))),
|
||||
|
||||
"/issues/by-project": withGlobal(withAuth(routes.issuesByProject)),
|
||||
"/issues/replace-status": withGlobal(withAuth(withCSRF(routes.issuesReplaceStatus))),
|
||||
"/issues/replace-type": withGlobal(withAuth(withCSRF(routes.issuesReplaceType))),
|
||||
"/issues/status-count": withGlobal(withAuth(routes.issuesStatusCount)),
|
||||
"/issues/type-count": withGlobal(withAuth(routes.issuesTypeCount)),
|
||||
"/issues/all": withGlobal(withAuth(routes.issues)),
|
||||
"/issue-comments/by-issue": withGlobal(withAuth(routes.issueCommentsByIssue)),
|
||||
"/issues/by-project": withGlobalAuthed(withAuth(routes.issuesByProject)),
|
||||
"/issues/replace-status": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceStatus))),
|
||||
"/issues/replace-type": withGlobalAuthed(withAuth(withCSRF(routes.issuesReplaceType))),
|
||||
"/issues/status-count": withGlobalAuthed(withAuth(routes.issuesStatusCount)),
|
||||
"/issues/type-count": withGlobalAuthed(withAuth(routes.issuesTypeCount)),
|
||||
"/issues/all": withGlobalAuthed(withAuth(routes.issues)),
|
||||
"/issue-comments/by-issue": withGlobalAuthed(withAuth(routes.issueCommentsByIssue)),
|
||||
|
||||
"/organisation/create": withGlobal(withAuth(withCSRF(routes.organisationCreate))),
|
||||
"/organisation/by-id": withGlobal(withAuth(routes.organisationById)),
|
||||
"/organisation/update": withGlobal(withAuth(withCSRF(routes.organisationUpdate))),
|
||||
"/organisation/delete": withGlobal(withAuth(withCSRF(routes.organisationDelete))),
|
||||
"/organisation/upload-icon": withGlobal(withAuth(withCSRF(routes.organisationUploadIcon))),
|
||||
"/organisation/add-member": withGlobal(withAuth(withCSRF(routes.organisationAddMember))),
|
||||
"/organisation/members": withGlobal(withAuth(routes.organisationMembers)),
|
||||
"/organisation/remove-member": withGlobal(withAuth(withCSRF(routes.organisationRemoveMember))),
|
||||
"/organisation/update-member-role": withGlobal(
|
||||
"/organisation/create": withGlobalAuthed(withAuth(withCSRF(routes.organisationCreate))),
|
||||
"/organisation/by-id": withGlobalAuthed(withAuth(routes.organisationById)),
|
||||
"/organisation/update": withGlobalAuthed(withAuth(withCSRF(routes.organisationUpdate))),
|
||||
"/organisation/delete": withGlobalAuthed(withAuth(withCSRF(routes.organisationDelete))),
|
||||
"/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
|
||||
"/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))),
|
||||
"/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)),
|
||||
"/organisation/remove-member": withGlobalAuthed(
|
||||
withAuth(withCSRF(routes.organisationRemoveMember)),
|
||||
),
|
||||
"/organisation/update-member-role": withGlobalAuthed(
|
||||
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/update": withGlobal(withAuth(withCSRF(routes.projectUpdate))),
|
||||
"/project/delete": withGlobal(withAuth(withCSRF(routes.projectDelete))),
|
||||
"/project/with-creator": withGlobal(withAuth(routes.projectWithCreator)),
|
||||
"/project/create": withGlobalAuthed(withAuth(withCSRF(routes.projectCreate))),
|
||||
"/project/update": withGlobalAuthed(withAuth(withCSRF(routes.projectUpdate))),
|
||||
"/project/delete": withGlobalAuthed(withAuth(withCSRF(routes.projectDelete))),
|
||||
"/project/with-creator": withGlobalAuthed(withAuth(routes.projectWithCreator)),
|
||||
|
||||
"/projects/by-creator": withGlobal(withAuth(routes.projectsByCreator)),
|
||||
"/projects/by-organisation": withGlobal(withAuth(routes.projectsByOrganisation)),
|
||||
"/projects/all": withGlobal(withAuth(routes.projectsAll)),
|
||||
"/projects/with-creators": withGlobal(withAuth(routes.projectsWithCreators)),
|
||||
"/projects/by-creator": withGlobalAuthed(withAuth(routes.projectsByCreator)),
|
||||
"/projects/by-organisation": withGlobalAuthed(withAuth(routes.projectsByOrganisation)),
|
||||
"/projects/all": withGlobalAuthed(withAuth(routes.projectsAll)),
|
||||
"/projects/with-creators": withGlobalAuthed(withAuth(routes.projectsWithCreators)),
|
||||
|
||||
"/sprint/create": withGlobal(withAuth(withCSRF(routes.sprintCreate))),
|
||||
"/sprint/update": withGlobal(withAuth(withCSRF(routes.sprintUpdate))),
|
||||
"/sprint/delete": withGlobal(withAuth(withCSRF(routes.sprintDelete))),
|
||||
"/sprints/by-project": withGlobal(withAuth(routes.sprintsByProject)),
|
||||
"/sprint/create": withGlobalAuthed(withAuth(withCSRF(routes.sprintCreate))),
|
||||
"/sprint/update": withGlobalAuthed(withAuth(withCSRF(routes.sprintUpdate))),
|
||||
"/sprint/delete": withGlobalAuthed(withAuth(withCSRF(routes.sprintDelete))),
|
||||
"/sprints/by-project": withGlobalAuthed(withAuth(routes.sprintsByProject)),
|
||||
|
||||
"/timer/toggle": withGlobal(withAuth(withCSRF(routes.timerToggle))),
|
||||
"/timer/end": withGlobal(withAuth(withCSRF(routes.timerEnd))),
|
||||
"/timer/get": withGlobal(withAuth(withCSRF(routes.timerGet))),
|
||||
"/timer/get-inactive": withGlobal(withAuth(withCSRF(routes.timerGetInactive))),
|
||||
"/timers": withGlobal(withAuth(withCSRF(routes.timers))),
|
||||
"/timer/toggle": withGlobalAuthed(withAuth(withCSRF(routes.timerToggle))),
|
||||
"/timer/end": withGlobalAuthed(withAuth(withCSRF(routes.timerEnd))),
|
||||
"/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))),
|
||||
"/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))),
|
||||
"/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
165
packages/backend/src/logger.ts
Normal file
165
packages/backend/src/logger.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@nsmr/pixelart-react": "^2.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import type { UserResponse } from "@sprint/shared";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { useAddOrganisationMember } from "@/lib/query/hooks";
|
||||
import { parseError, user } from "@/lib/server";
|
||||
import { apiClient, parseError } from "@/lib/server";
|
||||
|
||||
export function AddMember({
|
||||
organisationId,
|
||||
@@ -23,7 +23,7 @@ export function AddMember({
|
||||
organisationId: number;
|
||||
existingMembers: string[];
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: (user: UserRecord) => void | Promise<void>;
|
||||
onSuccess?: (user: UserResponse) => void | Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -62,7 +62,10 @@ export function AddMember({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const userData: UserRecord = await user.byUsername(username);
|
||||
const { data, error } = await apiClient.userByUsername({ query: { username } });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("user not found");
|
||||
const userData = data as UserResponse;
|
||||
const userId = userData.id;
|
||||
await addMember.mutateAsync({ organisationId, userId, role: "member" });
|
||||
setOpen(false);
|
||||
|
||||
@@ -34,10 +34,8 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
|
||||
|
||||
const sortedComments = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
const aDate =
|
||||
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0);
|
||||
const bDate =
|
||||
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
|
||||
const aDate = a.Comment.createdAt ? new Date(a.Comment.createdAt) : new Date(0);
|
||||
const bDate = b.Comment.createdAt ? new Date(b.Comment.createdAt) : new Date(0);
|
||||
return aDate.getTime() - bDate.getTime();
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared";
|
||||
import type { IssueResponse, SprintRecord, UserResponse } from "@sprint/shared";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { IssueComments } from "@/components/issue-comments";
|
||||
@@ -51,7 +51,7 @@ export function IssueDetails({
|
||||
issueData: IssueResponse;
|
||||
projectKey: string;
|
||||
sprints: SprintRecord[];
|
||||
members: UserRecord[];
|
||||
members: UserResponse[];
|
||||
statuses: Record<string, string>;
|
||||
onClose: () => void;
|
||||
onDelete?: () => void;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import type { UserResponse } from "@sprint/shared";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { UserSelect } from "@/components/user-select";
|
||||
@@ -9,10 +9,10 @@ export function MultiAssigneeSelect({
|
||||
onChange,
|
||||
fallbackUsers = [],
|
||||
}: {
|
||||
users: UserRecord[];
|
||||
users: UserResponse[];
|
||||
assigneeIds: string[];
|
||||
onChange: (assigneeIds: string[]) => void;
|
||||
fallbackUsers?: UserRecord[];
|
||||
fallbackUsers?: UserResponse[];
|
||||
}) {
|
||||
const handleAssigneeChange = (index: number, value: string) => {
|
||||
// if set to "unassigned" and there are other rows, remove this row
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ORG_DESCRIPTION_MAX_LENGTH,
|
||||
ORG_NAME_MAX_LENGTH,
|
||||
ORG_SLUG_MAX_LENGTH,
|
||||
type OrganisationRecord,
|
||||
type OrganisationRecordType,
|
||||
} from "@sprint/shared";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -41,10 +41,10 @@ export function OrganisationForm({
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: {
|
||||
trigger?: React.ReactNode;
|
||||
completeAction?: (org: OrganisationRecord) => void | Promise<void>;
|
||||
completeAction?: (org: OrganisationRecordType) => void | Promise<void>;
|
||||
errorAction?: (errorMessage: string) => void | Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
existingOrganisation?: OrganisationRecord;
|
||||
existingOrganisation?: OrganisationRecordType;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
useUpdateOrganisationMemberRole,
|
||||
} from "@/lib/query/hooks";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { issue } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
import { capitalise, unCamelCase } from "@/lib/utils";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
@@ -370,8 +370,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const handleRemoveStatusClick = async (status: string) => {
|
||||
if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return;
|
||||
try {
|
||||
const data = await issue.statusCount(selectedOrganisation.Organisation.id, status);
|
||||
const count = data.find((item) => item.status === status)?.count ?? 0;
|
||||
const { data, error } = await apiClient.issuesStatusCount({
|
||||
query: { organisationId: selectedOrganisation.Organisation.id, status },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
const statusCounts = (data ?? []) as { status: string; count: number }[];
|
||||
const count = statusCounts.find((item) => item.status === status)?.count ?? 0;
|
||||
if (count > 0) {
|
||||
setStatusToRemove(status);
|
||||
setIssuesUsingStatus(count);
|
||||
@@ -546,8 +550,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const handleRemoveTypeClick = async (typeName: string) => {
|
||||
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
|
||||
try {
|
||||
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName);
|
||||
const count = data.count ?? 0;
|
||||
const { data, error } = await apiClient.issuesTypeCount({
|
||||
query: { organisationId: selectedOrganisation.Organisation.id, type: typeName },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
const typeCount = (data ?? { count: 0 }) as { count: number };
|
||||
const count = typeCount.count ?? 0;
|
||||
if (count > 0) {
|
||||
setTypeToRemove(typeName);
|
||||
setIssuesUsingType(count);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import type { UserResponse } from "@sprint/shared";
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Loading from "@/components/loading";
|
||||
@@ -6,8 +6,8 @@ import { LoginModal } from "@/components/login-modal";
|
||||
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
|
||||
|
||||
interface SessionContextValue {
|
||||
user: UserRecord | null;
|
||||
setUser: (user: UserRecord) => void;
|
||||
user: UserResponse | null;
|
||||
setUser: (user: UserResponse) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useSessionSafe(): SessionContextValue | null {
|
||||
}
|
||||
|
||||
// for use inside RequireAuth
|
||||
export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: UserRecord) => void } {
|
||||
export function useAuthenticatedSession(): { user: UserResponse; setUser: (user: UserResponse) => void } {
|
||||
const { user, setUser } = useSession();
|
||||
if (!user) {
|
||||
throw new Error("useAuthenticatedSession must be used within RequireAuth");
|
||||
@@ -37,11 +37,11 @@ export function useAuthenticatedSession(): { user: UserRecord; setUser: (user: U
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUserState] = useState<UserRecord | null>(null);
|
||||
const [user, setUserState] = useState<UserResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const fetched = useRef(false);
|
||||
|
||||
const setUser = useCallback((user: UserRecord) => {
|
||||
const setUser = useCallback((user: UserResponse) => {
|
||||
setUserState(user);
|
||||
localStorage.setItem("user", JSON.stringify(user));
|
||||
}, []);
|
||||
@@ -57,7 +57,7 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!res.ok) {
|
||||
throw new Error(`auth check failed: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { user: UserRecord; csrfToken: string };
|
||||
const data = (await res.json()) as { user: UserResponse; csrfToken: string };
|
||||
setUser(data.user);
|
||||
setCsrfToken(data.csrfToken);
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import type { UserResponse } from "@sprint/shared";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function SmallUserDisplay({ user, className }: { user: UserRecord; className?: string }) {
|
||||
export default function SmallUserDisplay({ user, className }: { user: UserResponse; className?: string }) {
|
||||
return (
|
||||
<div className={cn("flex gap-2 items-center", className)}>
|
||||
<Avatar
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SprintRecord, UserRecord } from "@sprint/shared";
|
||||
import type { SprintRecord } from "@sprint/shared";
|
||||
import { useState } from "react";
|
||||
import SmallSprintDisplay from "@/components/small-sprint-display";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -12,7 +12,6 @@ export function SprintSelect({
|
||||
sprints: SprintRecord[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UserRecord } from "@sprint/shared";
|
||||
import type { UserResponse } from "@sprint/shared";
|
||||
import { useState } from "react";
|
||||
import SmallUserDisplay from "@/components/small-user-display";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -10,10 +10,10 @@ export function UserSelect({
|
||||
fallbackUser,
|
||||
placeholder = "Select user",
|
||||
}: {
|
||||
users: UserRecord[];
|
||||
users: UserResponse[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
fallbackUser?: UserRecord | null;
|
||||
fallbackUser?: UserResponse | null;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
103
packages/frontend/src/lib/api-client.ts
Normal file
103
packages/frontend/src/lib/api-client.ts
Normal 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 };
|
||||
@@ -7,12 +7,18 @@ import type {
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { issueComment } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useIssueComments(issueId?: number | null) {
|
||||
return useQuery<IssueCommentResponse[]>({
|
||||
queryKey: queryKeys.issueComments.byIssue(issueId ?? 0),
|
||||
queryFn: () => issueComment.byIssue(issueId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.issueCommentsByIssue({
|
||||
query: { issueId: issueId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as IssueCommentResponse[];
|
||||
},
|
||||
enabled: Boolean(issueId),
|
||||
});
|
||||
}
|
||||
@@ -22,7 +28,12 @@ export function useCreateIssueComment() {
|
||||
|
||||
return useMutation<IssueCommentRecord, Error, IssueCommentCreateRequest>({
|
||||
mutationKey: ["issue-comments", "create"],
|
||||
mutationFn: issueComment.create,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issueCommentCreate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create comment");
|
||||
return data as IssueCommentRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issueComments.byIssue(variables.issueId),
|
||||
@@ -36,7 +47,12 @@ export function useDeleteIssueComment() {
|
||||
|
||||
return useMutation<SuccessResponse, Error, IssueCommentDeleteRequest>({
|
||||
mutationKey: ["issue-comments", "delete"],
|
||||
mutationFn: issueComment.delete,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issueCommentDelete({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to delete comment");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issueComments.all });
|
||||
},
|
||||
|
||||
@@ -12,12 +12,18 @@ import type {
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { issue } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useIssues(projectId?: number | null) {
|
||||
return useQuery<IssueResponse[]>({
|
||||
queryKey: queryKeys.issues.byProject(projectId ?? 0),
|
||||
queryFn: () => issue.byProject(projectId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.issuesByProject({
|
||||
query: { projectId: projectId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as IssueResponse[];
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
@@ -25,7 +31,14 @@ export function useIssues(projectId?: number | null) {
|
||||
export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
|
||||
return useQuery<IssueResponse>({
|
||||
queryKey: queryKeys.issues.byId(issueId ?? 0),
|
||||
queryFn: () => issue.byId(issueId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.issueById({
|
||||
query: { issueId: issueId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("issue not found");
|
||||
return data as IssueResponse;
|
||||
},
|
||||
enabled: Boolean(issueId),
|
||||
});
|
||||
}
|
||||
@@ -33,7 +46,13 @@ export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
|
||||
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
|
||||
return useQuery<StatusCountResponse>({
|
||||
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
|
||||
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.issuesStatusCount({
|
||||
query: { organisationId: organisationId ?? 0, status: status ?? "" },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as StatusCountResponse;
|
||||
},
|
||||
enabled: Boolean(organisationId && status),
|
||||
});
|
||||
}
|
||||
@@ -43,7 +62,12 @@ export function useCreateIssue() {
|
||||
|
||||
return useMutation<IssueRecord, Error, IssueCreateRequest>({
|
||||
mutationKey: ["issues", "create"],
|
||||
mutationFn: issue.create,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issueCreate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create issue");
|
||||
return data as IssueRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.byProject(variables.projectId),
|
||||
@@ -57,7 +81,12 @@ export function useUpdateIssue() {
|
||||
|
||||
return useMutation<IssueRecord, Error, IssueUpdateRequest>({
|
||||
mutationKey: ["issues", "update"],
|
||||
mutationFn: issue.update,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issueUpdate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update issue");
|
||||
return data as IssueRecord;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
@@ -69,7 +98,12 @@ export function useDeleteIssue() {
|
||||
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["issues", "delete"],
|
||||
mutationFn: issue.delete,
|
||||
mutationFn: async (issueId) => {
|
||||
const { data, error } = await apiClient.issueDelete({ body: { id: issueId } });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to delete issue");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
@@ -81,7 +115,11 @@ export function useReplaceIssueStatus() {
|
||||
|
||||
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
|
||||
mutationKey: ["issues", "replace-status"],
|
||||
mutationFn: issue.replaceStatus,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issuesReplaceStatus({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
return data as unknown;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
@@ -91,7 +129,14 @@ export function useReplaceIssueStatus() {
|
||||
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
|
||||
return useQuery<TypeCountResponse>({
|
||||
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""),
|
||||
queryFn: () => issue.typeCount(organisationId ?? 0, type ?? ""),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.issuesTypeCount({
|
||||
query: { organisationId: organisationId ?? 0, type: type ?? "" },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to get type count");
|
||||
return data as TypeCountResponse;
|
||||
},
|
||||
enabled: Boolean(organisationId && type),
|
||||
});
|
||||
}
|
||||
@@ -101,7 +146,11 @@ export function useReplaceIssueType() {
|
||||
|
||||
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
|
||||
mutationKey: ["issues", "replace-type"],
|
||||
mutationFn: issue.replaceType,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.issuesReplaceType({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
return data as unknown;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
|
||||
@@ -2,22 +2,39 @@ import type {
|
||||
OrgAddMemberRequest,
|
||||
OrganisationMemberRecord,
|
||||
OrganisationMemberResponse,
|
||||
OrganisationRecordType,
|
||||
OrganisationResponse,
|
||||
OrgCreateRequest,
|
||||
OrgRemoveMemberRequest,
|
||||
OrgUpdateMemberRoleRequest,
|
||||
OrgUpdateRequest,
|
||||
SuccessResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { organisation } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useOrganisations() {
|
||||
return useQuery({
|
||||
return useQuery<OrganisationResponse[]>({
|
||||
queryKey: queryKeys.organisations.byUser(),
|
||||
queryFn: organisation.byUser,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.organisationsByUser();
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as OrganisationResponse[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrganisationMembers(organisationId?: number | null) {
|
||||
return useQuery<OrganisationMemberResponse[]>({
|
||||
queryKey: queryKeys.organisations.members(organisationId ?? 0),
|
||||
queryFn: () => organisation.members(organisationId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.organisationMembers({
|
||||
query: { organisationId: organisationId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as OrganisationMemberResponse[];
|
||||
},
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
}
|
||||
@@ -25,9 +42,14 @@ export function useOrganisationMembers(organisationId?: number | null) {
|
||||
export function useCreateOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<OrganisationRecordType, Error, OrgCreateRequest>({
|
||||
mutationKey: ["organisations", "create"],
|
||||
mutationFn: organisation.create,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.organisationCreate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create organisation");
|
||||
return data as OrganisationRecordType;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
@@ -37,9 +59,14 @@ export function useCreateOrganisation() {
|
||||
export function useUpdateOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<OrganisationRecordType, Error, OrgUpdateRequest>({
|
||||
mutationKey: ["organisations", "update"],
|
||||
mutationFn: organisation.update,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.organisationUpdate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update organisation");
|
||||
return data as OrganisationRecordType;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
@@ -49,9 +76,14 @@ export function useUpdateOrganisation() {
|
||||
export function useDeleteOrganisation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["organisations", "delete"],
|
||||
mutationFn: organisation.remove,
|
||||
mutationFn: async (orgId) => {
|
||||
const { data, error } = await apiClient.organisationDelete({ body: { id: orgId } });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to delete organisation");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
@@ -63,7 +95,12 @@ export function useAddOrganisationMember() {
|
||||
|
||||
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
|
||||
mutationKey: ["organisations", "members", "add"],
|
||||
mutationFn: organisation.addMember,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.organisationAddMember({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to add member");
|
||||
return data as OrganisationMemberRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
@@ -75,9 +112,14 @@ export function useAddOrganisationMember() {
|
||||
export function useRemoveOrganisationMember() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<SuccessResponse, Error, OrgRemoveMemberRequest>({
|
||||
mutationKey: ["organisations", "members", "remove"],
|
||||
mutationFn: organisation.removeMember,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.organisationRemoveMember({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to remove member");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
@@ -89,9 +131,14 @@ export function useRemoveOrganisationMember() {
|
||||
export function useUpdateOrganisationMemberRole() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<OrganisationMemberRecord, Error, OrgUpdateMemberRoleRequest>({
|
||||
mutationKey: ["organisations", "members", "update-role"],
|
||||
mutationFn: organisation.updateMemberRole,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.organisationUpdateMemberRole({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update member role");
|
||||
return data as OrganisationMemberRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.organisations.members(variables.organisationId),
|
||||
@@ -105,7 +152,15 @@ export function useUploadOrganisationIcon() {
|
||||
|
||||
return useMutation<string, Error, { file: File; organisationId: number }>({
|
||||
mutationKey: ["organisations", "upload-icon"],
|
||||
mutationFn: ({ file, organisationId }) => organisation.uploadIcon(file, organisationId),
|
||||
mutationFn: async ({ file, organisationId }) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("organisationId", `${organisationId}`);
|
||||
const { data, error } = await apiClient.organisationUploadIcon({ body: formData });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to upload organisation icon");
|
||||
return (data as { iconURL: string }).iconURL;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
|
||||
},
|
||||
|
||||
@@ -3,15 +3,22 @@ import type {
|
||||
ProjectRecord,
|
||||
ProjectResponse,
|
||||
ProjectUpdateRequest,
|
||||
SuccessResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { project } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useProjects(organisationId?: number | null) {
|
||||
return useQuery<ProjectResponse[]>({
|
||||
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
|
||||
queryFn: () => project.byOrganisation(organisationId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.projectsByOrganisation({
|
||||
query: { organisationId: organisationId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as ProjectResponse[];
|
||||
},
|
||||
enabled: Boolean(organisationId),
|
||||
});
|
||||
}
|
||||
@@ -21,7 +28,12 @@ export function useCreateProject() {
|
||||
|
||||
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
|
||||
mutationKey: ["projects", "create"],
|
||||
mutationFn: project.create,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.projectCreate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create project");
|
||||
return data as ProjectRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
|
||||
@@ -35,7 +47,12 @@ export function useUpdateProject() {
|
||||
|
||||
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
|
||||
mutationKey: ["projects", "update"],
|
||||
mutationFn: project.update,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.projectUpdate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update project");
|
||||
return data as ProjectRecord;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
@@ -45,9 +62,14 @@ export function useUpdateProject() {
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["projects", "delete"],
|
||||
mutationFn: project.remove,
|
||||
mutationFn: async (projectId) => {
|
||||
const { data, error } = await apiClient.projectDelete({ body: { id: projectId } });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to delete project");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { SprintCreateRequest, SprintRecord, SprintUpdateRequest } from "@sprint/shared";
|
||||
import type { SprintCreateRequest, SprintRecord, SprintUpdateRequest, SuccessResponse } from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { sprint } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useSprints(projectId?: number | null) {
|
||||
return useQuery<SprintRecord[]>({
|
||||
queryKey: queryKeys.sprints.byProject(projectId ?? 0),
|
||||
queryFn: () => sprint.byProject(projectId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.sprintsByProject({
|
||||
query: { projectId: projectId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as SprintRecord[];
|
||||
},
|
||||
enabled: Boolean(projectId),
|
||||
});
|
||||
}
|
||||
@@ -16,7 +22,12 @@ export function useCreateSprint() {
|
||||
|
||||
return useMutation<SprintRecord, Error, SprintCreateRequest>({
|
||||
mutationKey: ["sprints", "create"],
|
||||
mutationFn: sprint.create,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.sprintCreate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to create sprint");
|
||||
return data as SprintRecord;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
|
||||
},
|
||||
@@ -28,7 +39,12 @@ export function useUpdateSprint() {
|
||||
|
||||
return useMutation<SprintRecord, Error, SprintUpdateRequest>({
|
||||
mutationKey: ["sprints", "update"],
|
||||
mutationFn: sprint.update,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.sprintUpdate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update sprint");
|
||||
return data as SprintRecord;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
@@ -38,9 +54,14 @@ export function useUpdateSprint() {
|
||||
export function useDeleteSprint() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useMutation<SuccessResponse, Error, number>({
|
||||
mutationKey: ["sprints", "delete"],
|
||||
mutationFn: sprint.remove,
|
||||
mutationFn: async (sprintId) => {
|
||||
const { data, error } = await apiClient.sprintDelete({ body: { id: sprintId } });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to delete sprint");
|
||||
return data as SuccessResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
|
||||
},
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { timer } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
const activeTimersQueryFn = () => timer.list({ activeOnly: true });
|
||||
const activeTimersQueryFn = async () => {
|
||||
const { data, error } = await apiClient.timers({ query: { activeOnly: true } });
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as TimerListItem[];
|
||||
};
|
||||
|
||||
export function useActiveTimers(options?: { refetchInterval?: number; enabled?: boolean }) {
|
||||
return useQuery<TimerListItem[]>({
|
||||
@@ -29,7 +33,13 @@ export function useTimerState(issueId?: number | null, options?: { refetchInterv
|
||||
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
|
||||
return useQuery<TimerState[]>({
|
||||
queryKey: queryKeys.timers.inactive(issueId ?? 0),
|
||||
queryFn: () => timer.getInactive(issueId ?? 0),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.timerGetInactive({
|
||||
query: { issueId: issueId ?? 0 },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
return (data ?? []) as TimerState[];
|
||||
},
|
||||
enabled: Boolean(issueId),
|
||||
refetchInterval: options?.refetchInterval,
|
||||
refetchIntervalInBackground: false,
|
||||
@@ -41,7 +51,12 @@ export function useToggleTimer() {
|
||||
|
||||
return useMutation<TimerState, Error, TimerToggleRequest>({
|
||||
mutationKey: ["timers", "toggle"],
|
||||
mutationFn: timer.toggle,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.timerToggle({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to toggle timer");
|
||||
return data as TimerState;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
|
||||
@@ -54,7 +69,12 @@ export function useEndTimer() {
|
||||
|
||||
return useMutation<TimerState, Error, TimerEndRequest>({
|
||||
mutationKey: ["timers", "end"],
|
||||
mutationFn: timer.end,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.timerEnd({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to end timer");
|
||||
return data as TimerState;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { UserRecord, UserUpdateRequest } from "@sprint/shared";
|
||||
import type { UserResponse, UserUpdateRequest } from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
import { user } from "@/lib/server";
|
||||
import { apiClient } from "@/lib/server";
|
||||
|
||||
export function useUserByUsername(username?: string | null) {
|
||||
return useQuery<UserRecord>({
|
||||
return useQuery<UserResponse>({
|
||||
queryKey: queryKeys.users.byUsername(username ?? ""),
|
||||
queryFn: () => user.byUsername(username ?? ""),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await apiClient.userByUsername({
|
||||
query: { username: username ?? "" },
|
||||
});
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("user not found");
|
||||
return data as UserResponse;
|
||||
},
|
||||
enabled: Boolean(username),
|
||||
});
|
||||
}
|
||||
@@ -14,9 +21,14 @@ export function useUserByUsername(username?: string | null) {
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UserRecord, Error, UserUpdateRequest>({
|
||||
return useMutation<UserResponse, Error, UserUpdateRequest>({
|
||||
mutationKey: ["users", "update"],
|
||||
mutationFn: user.update,
|
||||
mutationFn: async (input) => {
|
||||
const { data, error } = await apiClient.userUpdate({ body: input });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to update user");
|
||||
return data as UserResponse;
|
||||
},
|
||||
onSuccess: (_data) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
@@ -28,7 +40,14 @@ export function useUploadAvatar() {
|
||||
|
||||
return useMutation<string, Error, File>({
|
||||
mutationKey: ["users", "upload-avatar"],
|
||||
mutationFn: user.uploadAvatar,
|
||||
mutationFn: async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data, error } = await apiClient.userUploadAvatar({ body: formData });
|
||||
if (error) throw new Error(error);
|
||||
if (!data) throw new Error("failed to upload avatar");
|
||||
return (data as { avatarURL: string }).avatarURL;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
},
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import type { ApiError } from "@sprint/shared";
|
||||
|
||||
export * as issue from "@/lib/server/issue";
|
||||
export * as issueComment from "@/lib/server/issue-comment";
|
||||
export * as organisation from "@/lib/server/organisation";
|
||||
export * as project from "@/lib/server/project";
|
||||
export * as sprint from "@/lib/server/sprint";
|
||||
export * as timer from "@/lib/server/timer";
|
||||
export * as user from "@/lib/server/user";
|
||||
|
||||
export async function getErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
const error = await res.json().catch(() => res.text());
|
||||
if (typeof error === "string") {
|
||||
return error || fallback;
|
||||
}
|
||||
if (error && typeof error === "object") {
|
||||
if ("details" in error && error.details) {
|
||||
const messages = Object.values(error.details as Record<string, string[]>).flat();
|
||||
if (messages.length > 0) return messages.join(", ");
|
||||
}
|
||||
if ("error" in error && typeof error.error === "string") {
|
||||
return error.error || fallback;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
export { apiClient } from "@/lib/api-client";
|
||||
|
||||
export function parseError(error: ApiError | string | Error): string {
|
||||
if (typeof error === "string") return error;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"drizzle-orm": "^0.45.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -57,6 +57,7 @@ export const AuthResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
username: z.string(),
|
||||
avatarURL: z.string().nullable(),
|
||||
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
|
||||
}),
|
||||
csrfToken: z.string(),
|
||||
});
|
||||
@@ -410,6 +411,7 @@ export const UserResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
username: z.string(),
|
||||
avatarURL: z.string().nullable(),
|
||||
iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
|
||||
createdAt: z.string().nullable().optional(),
|
||||
updatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
@@ -434,7 +436,7 @@ export const IssueResponseSchema = z.object({
|
||||
Assignees: z.array(UserResponseSchema),
|
||||
});
|
||||
|
||||
export type IssueResponseType = z.infer<typeof IssueResponseSchema>;
|
||||
export type IssueResponse = z.infer<typeof IssueResponseSchema>;
|
||||
|
||||
export const IssueCommentRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -450,18 +452,23 @@ export const IssueCommentResponseSchema = z.object({
|
||||
User: UserResponseSchema,
|
||||
});
|
||||
|
||||
export type IssueCommentResponseType = z.infer<typeof IssueCommentResponseSchema>;
|
||||
export type IssueCommentResponse = z.infer<typeof IssueCommentResponseSchema>;
|
||||
|
||||
export const OrganisationRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
slug: z.string(),
|
||||
iconURL: z.string().nullable().optional(),
|
||||
statuses: z.record(z.string()),
|
||||
features: z.record(z.boolean()),
|
||||
issueTypes: z.record(z.object({ icon: z.string(), color: z.string() })),
|
||||
createdAt: z.string().nullable().optional(),
|
||||
updatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type OrganisationRecordType = z.infer<typeof OrganisationRecordSchema>;
|
||||
|
||||
export const OrganisationMemberRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
organisationId: z.number(),
|
||||
@@ -470,12 +477,22 @@ export const OrganisationMemberRecordSchema = z.object({
|
||||
createdAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type OrganisationMemberRecordType = z.infer<typeof OrganisationMemberRecordSchema>;
|
||||
|
||||
export const OrganisationResponseSchema = z.object({
|
||||
Organisation: OrganisationRecordSchema,
|
||||
OrganisationMember: OrganisationMemberRecordSchema,
|
||||
});
|
||||
|
||||
export type OrganisationResponseType = z.infer<typeof OrganisationResponseSchema>;
|
||||
export type OrganisationResponse = z.infer<typeof OrganisationResponseSchema>;
|
||||
|
||||
export const OrganisationMemberResponseSchema = z.object({
|
||||
OrganisationMember: OrganisationMemberRecordSchema,
|
||||
Organisation: OrganisationRecordSchema,
|
||||
User: UserResponseSchema,
|
||||
});
|
||||
|
||||
export type OrganisationMemberResponse = z.infer<typeof OrganisationMemberResponseSchema>;
|
||||
|
||||
export const ProjectRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -491,7 +508,14 @@ export const ProjectResponseSchema = z.object({
|
||||
User: UserResponseSchema,
|
||||
});
|
||||
|
||||
export type ProjectResponseType = z.infer<typeof ProjectResponseSchema>;
|
||||
export type ProjectResponse = z.infer<typeof ProjectResponseSchema>;
|
||||
|
||||
export const ProjectWithCreatorResponseSchema = z.object({
|
||||
Project: ProjectRecordSchema,
|
||||
User: UserResponseSchema,
|
||||
});
|
||||
|
||||
export type ProjectWithCreatorResponse = z.infer<typeof ProjectWithCreatorResponseSchema>;
|
||||
|
||||
export const SprintRecordSchema = z.object({
|
||||
id: z.number(),
|
||||
@@ -503,7 +527,7 @@ export const SprintRecordSchema = z.object({
|
||||
createdAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type SprintResponseType = z.infer<typeof SprintRecordSchema>;
|
||||
export type SprintResponse = z.infer<typeof SprintRecordSchema>;
|
||||
|
||||
export const TimerStateSchema = z
|
||||
.object({
|
||||
|
||||
580
packages/shared/src/contract.ts
Normal file
580
packages/shared/src/contract.ts
Normal 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;
|
||||
@@ -4,11 +4,11 @@ export type {
|
||||
IssueByIdQuery,
|
||||
IssueCommentCreateRequest,
|
||||
IssueCommentDeleteRequest,
|
||||
IssueCommentResponseType,
|
||||
IssueCommentResponse,
|
||||
IssueCommentsByIssueQuery,
|
||||
IssueCreateRequest,
|
||||
IssueDeleteRequest,
|
||||
IssueResponseType,
|
||||
IssueResponse,
|
||||
IssuesByProjectQuery,
|
||||
IssuesReplaceStatusRequest,
|
||||
IssuesReplaceTypeRequest,
|
||||
@@ -17,7 +17,10 @@ export type {
|
||||
IssueUpdateRequest,
|
||||
LoginRequest,
|
||||
OrgAddMemberRequest,
|
||||
OrganisationResponseType,
|
||||
OrganisationMemberRecordType,
|
||||
OrganisationMemberResponse,
|
||||
OrganisationRecordType,
|
||||
OrganisationResponse,
|
||||
OrgByIdQuery,
|
||||
OrgCreateRequest,
|
||||
OrgDeleteRequest,
|
||||
@@ -30,14 +33,15 @@ export type {
|
||||
ProjectByOrgQuery,
|
||||
ProjectCreateRequest,
|
||||
ProjectDeleteRequest,
|
||||
ProjectResponseType,
|
||||
ProjectResponse,
|
||||
ProjectUpdateRequest,
|
||||
ProjectWithCreatorResponse,
|
||||
RegisterRequest,
|
||||
ReplaceStatusResponse,
|
||||
ReplaceTypeResponse,
|
||||
SprintCreateRequest,
|
||||
SprintDeleteRequest,
|
||||
SprintResponseType,
|
||||
SprintResponse,
|
||||
SprintsByProjectQuery,
|
||||
SprintUpdateRequest,
|
||||
StatusCountResponse,
|
||||
@@ -76,6 +80,7 @@ export {
|
||||
LoginRequestSchema,
|
||||
OrgAddMemberRequestSchema,
|
||||
OrganisationMemberRecordSchema,
|
||||
OrganisationMemberResponseSchema,
|
||||
OrganisationRecordSchema,
|
||||
OrganisationResponseSchema,
|
||||
OrgByIdQuerySchema,
|
||||
@@ -93,6 +98,7 @@ export {
|
||||
ProjectRecordSchema,
|
||||
ProjectResponseSchema,
|
||||
ProjectUpdateRequestSchema,
|
||||
ProjectWithCreatorResponseSchema,
|
||||
RegisterRequestSchema,
|
||||
ReplaceStatusResponseSchema,
|
||||
ReplaceTypeResponseSchema,
|
||||
@@ -129,25 +135,27 @@ export {
|
||||
USER_NAME_MAX_LENGTH,
|
||||
USER_USERNAME_MAX_LENGTH,
|
||||
} from "./constants";
|
||||
export type { ApiContract } from "./contract";
|
||||
export { apiContract } from "./contract";
|
||||
export type {
|
||||
IconStyle,
|
||||
IssueAssigneeInsert,
|
||||
IssueAssigneeRecord,
|
||||
IssueCommentInsert,
|
||||
IssueCommentRecord,
|
||||
IssueCommentResponse,
|
||||
IssueCommentResponse as IssueCommentResponseRecord,
|
||||
IssueInsert,
|
||||
IssueRecord,
|
||||
IssueResponse,
|
||||
IssueResponse as IssueResponseRecord,
|
||||
OrganisationInsert,
|
||||
OrganisationMemberInsert,
|
||||
OrganisationMemberRecord,
|
||||
OrganisationMemberResponse,
|
||||
OrganisationMemberResponse as OrganisationMemberResponseRecord,
|
||||
OrganisationRecord,
|
||||
OrganisationResponse,
|
||||
OrganisationResponse as OrganisationResponseRecord,
|
||||
ProjectInsert,
|
||||
ProjectRecord,
|
||||
ProjectResponse,
|
||||
ProjectResponse as ProjectResponseRecord,
|
||||
SessionInsert,
|
||||
SessionRecord,
|
||||
SprintInsert,
|
||||
|
||||
Reference in New Issue
Block a user