diff --git a/bun.lock b/bun.lock index 98390f5..c15f919 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/backend/src/db/queries/issue-comments.ts b/packages/backend/src/db/queries/issue-comments.ts index fa80caa..09be05e 100644 --- a/packages/backend/src/db/queries/issue-comments.ts +++ b/packages/backend/src/db/queries/issue-comments.ts @@ -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 { +export async function getIssueCommentsByIssueId(issueId: number): Promise { const comments = await db .select({ Comment: IssueComment, diff --git a/packages/backend/src/db/queries/issues.ts b/packages/backend/src/db/queries/issues.ts index c47f52f..8466685 100644 --- a/packages/backend/src/db/queries/issues.ts +++ b/packages/backend/src/db/queries/issues.ts @@ -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 { +export async function getIssueWithUsersById(issueId: number): Promise { 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 { +export async function getIssuesWithUsersByProject(projectId: number): Promise { const Creator = aliasedTable(User, "Creator"); const issuesWithCreators = await db diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 31913c1..843ffec 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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 = (req: T) => Response | Promise; -const withGlobal = (handler: RouteHandler) => withCors(withRateLimit(handler)); +const withGlobal = (handler: RouteHandler) => + withLogging(withCors(withRateLimit(handler))); +const withGlobalAuthed = (handler: RouteHandler) => + 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))), }, }); diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts new file mode 100644 index 0000000..f5a31dc --- /dev/null +++ b/packages/backend/src/logger.ts @@ -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 = (req: T) => Response | Promise; + +export const withLogging = (handler: RouteHandler): RouteHandler => { + 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 = (handler: RouteHandler): RouteHandler => { + 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; + } + }; +}; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e9a5908..711e00c 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -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", diff --git a/packages/frontend/src/components/add-member.tsx b/packages/frontend/src/components/add-member.tsx index 38bc883..5d7b8b4 100644 --- a/packages/frontend/src/components/add-member.tsx +++ b/packages/frontend/src/components/add-member.tsx @@ -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; + onSuccess?: (user: UserResponse) => void | Promise; }) { 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); diff --git a/packages/frontend/src/components/issue-comments.tsx b/packages/frontend/src/components/issue-comments.tsx index 1370867..1812543 100644 --- a/packages/frontend/src/components/issue-comments.tsx +++ b/packages/frontend/src/components/issue-comments.tsx @@ -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]); diff --git a/packages/frontend/src/components/issue-details.tsx b/packages/frontend/src/components/issue-details.tsx index faa1be1..aae6748 100644 --- a/packages/frontend/src/components/issue-details.tsx +++ b/packages/frontend/src/components/issue-details.tsx @@ -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; onClose: () => void; onDelete?: () => void; diff --git a/packages/frontend/src/components/multi-assignee-select.tsx b/packages/frontend/src/components/multi-assignee-select.tsx index 3ce7c3f..340771d 100644 --- a/packages/frontend/src/components/multi-assignee-select.tsx +++ b/packages/frontend/src/components/multi-assignee-select.tsx @@ -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 diff --git a/packages/frontend/src/components/organisation-form.tsx b/packages/frontend/src/components/organisation-form.tsx index 3229c12..5e933ed 100644 --- a/packages/frontend/src/components/organisation-form.tsx +++ b/packages/frontend/src/components/organisation-form.tsx @@ -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; + completeAction?: (org: OrganisationRecordType) => void | Promise; errorAction?: (errorMessage: string) => void | Promise; mode?: "create" | "edit"; - existingOrganisation?: OrganisationRecord; + existingOrganisation?: OrganisationRecordType; open?: boolean; onOpenChange?: (open: boolean) => void; }) { diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index 0a5b3b9..f0e6cd2 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -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); diff --git a/packages/frontend/src/components/session-provider.tsx b/packages/frontend/src/components/session-provider.tsx index 4b0966a..4eef767 100644 --- a/packages/frontend/src/components/session-provider.tsx +++ b/packages/frontend/src/components/session-provider.tsx @@ -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(null); + const [user, setUserState] = useState(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); }) diff --git a/packages/frontend/src/components/small-user-display.tsx b/packages/frontend/src/components/small-user-display.tsx index eae8112..3f1ad18 100644 --- a/packages/frontend/src/components/small-user-display.tsx +++ b/packages/frontend/src/components/small-user-display.tsx @@ -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 (
void; - fallbackUser?: UserRecord | null; placeholder?: string; }) { const [isOpen, setIsOpen] = useState(false); diff --git a/packages/frontend/src/components/user-select.tsx b/packages/frontend/src/components/user-select.tsx index 829a062..2eadd51 100644 --- a/packages/frontend/src/components/user-select.tsx +++ b/packages/frontend/src/components/user-select.tsx @@ -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); diff --git a/packages/frontend/src/lib/api-client.ts b/packages/frontend/src/lib/api-client.ts new file mode 100644 index 0000000..8172f28 --- /dev/null +++ b/packages/frontend/src/lib/api-client.ts @@ -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 = { + 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( + responsePromise: Promise, +): Promise> { + 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 = { + [K in keyof T]: T[K] extends (...args: infer A) => Promise + ? (...args: A) => Promise> + : T[K] extends object + ? WrappedClient + : T[K]; +}; + +function wrapClient(router: TRouter, client: unknown): unknown { + const entries = Object.entries(router).map(([key, route]) => { + const value = (client as Record)[key]; + if (isAppRoute(route) && typeof value === "function") { + return [ + key, + async (input?: { body?: unknown; query?: unknown; headers?: Record }) => { + const validationError = validateRequest(route, input); + if (validationError) { + return { data: null, error: validationError, status: 0 } as ApiResult; + } + 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; +export type { ApiResult }; diff --git a/packages/frontend/src/lib/query/hooks/issue-comments.ts b/packages/frontend/src/lib/query/hooks/issue-comments.ts index 5bb70ad..4f9f0b7 100644 --- a/packages/frontend/src/lib/query/hooks/issue-comments.ts +++ b/packages/frontend/src/lib/query/hooks/issue-comments.ts @@ -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({ 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({ 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({ 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 }); }, diff --git a/packages/frontend/src/lib/query/hooks/issues.ts b/packages/frontend/src/lib/query/hooks/issues.ts index c4600fd..06957af 100644 --- a/packages/frontend/src/lib/query/hooks/issues.ts +++ b/packages/frontend/src/lib/query/hooks/issues.ts @@ -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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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 }); }, diff --git a/packages/frontend/src/lib/query/hooks/organisations.ts b/packages/frontend/src/lib/query/hooks/organisations.ts index cfb4448..41188f0 100644 --- a/packages/frontend/src/lib/query/hooks/organisations.ts +++ b/packages/frontend/src/lib/query/hooks/organisations.ts @@ -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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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({ 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() }); }, diff --git a/packages/frontend/src/lib/query/hooks/projects.ts b/packages/frontend/src/lib/query/hooks/projects.ts index 1b06c39..4385370 100644 --- a/packages/frontend/src/lib/query/hooks/projects.ts +++ b/packages/frontend/src/lib/query/hooks/projects.ts @@ -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({ 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({ 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({ 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({ 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 }); }, diff --git a/packages/frontend/src/lib/query/hooks/sprints.ts b/packages/frontend/src/lib/query/hooks/sprints.ts index fd93c9a..e061c92 100644 --- a/packages/frontend/src/lib/query/hooks/sprints.ts +++ b/packages/frontend/src/lib/query/hooks/sprints.ts @@ -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({ 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({ 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({ 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({ 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 }); }, diff --git a/packages/frontend/src/lib/query/hooks/timers.ts b/packages/frontend/src/lib/query/hooks/timers.ts index 97bc6fe..751260b 100644 --- a/packages/frontend/src/lib/query/hooks/timers.ts +++ b/packages/frontend/src/lib/query/hooks/timers.ts @@ -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({ @@ -29,7 +33,13 @@ export function useTimerState(issueId?: number | null, options?: { refetchInterv export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) { return useQuery({ 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({ 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({ 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() }); diff --git a/packages/frontend/src/lib/query/hooks/users.ts b/packages/frontend/src/lib/query/hooks/users.ts index 0e7aa82..3cf04d0 100644 --- a/packages/frontend/src/lib/query/hooks/users.ts +++ b/packages/frontend/src/lib/query/hooks/users.ts @@ -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({ + return useQuery({ 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({ + return useMutation({ 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({ 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 }); }, diff --git a/packages/frontend/src/lib/server/index.ts b/packages/frontend/src/lib/server/index.ts index 2e6f6b3..0883108 100644 --- a/packages/frontend/src/lib/server/index.ts +++ b/packages/frontend/src/lib/server/index.ts @@ -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 { - 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).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; diff --git a/packages/frontend/src/lib/server/issue-comment/byIssue.ts b/packages/frontend/src/lib/server/issue-comment/byIssue.ts deleted file mode 100644 index c49843f..0000000 --- a/packages/frontend/src/lib/server/issue-comment/byIssue.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue-comment/create.ts b/packages/frontend/src/lib/server/issue-comment/create.ts deleted file mode 100644 index 11ee503..0000000 --- a/packages/frontend/src/lib/server/issue-comment/create.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/issue-comment/delete.ts b/packages/frontend/src/lib/server/issue-comment/delete.ts deleted file mode 100644 index be8878a..0000000 --- a/packages/frontend/src/lib/server/issue-comment/delete.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue-comment/index.ts b/packages/frontend/src/lib/server/issue-comment/index.ts deleted file mode 100644 index 2411054..0000000 --- a/packages/frontend/src/lib/server/issue-comment/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/issue/byId.ts b/packages/frontend/src/lib/server/issue/byId.ts deleted file mode 100644 index 5679891..0000000 --- a/packages/frontend/src/lib/server/issue/byId.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/byProject.ts b/packages/frontend/src/lib/server/issue/byProject.ts deleted file mode 100644 index e9a4549..0000000 --- a/packages/frontend/src/lib/server/issue/byProject.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/create.ts b/packages/frontend/src/lib/server/issue/create.ts deleted file mode 100644 index e01017c..0000000 --- a/packages/frontend/src/lib/server/issue/create.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/issue/delete.ts b/packages/frontend/src/lib/server/issue/delete.ts deleted file mode 100644 index 8735382..0000000 --- a/packages/frontend/src/lib/server/issue/delete.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/index.ts b/packages/frontend/src/lib/server/issue/index.ts deleted file mode 100644 index 12bcec5..0000000 --- a/packages/frontend/src/lib/server/issue/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/issue/replaceStatus.ts b/packages/frontend/src/lib/server/issue/replaceStatus.ts deleted file mode 100644 index 2a263c0..0000000 --- a/packages/frontend/src/lib/server/issue/replaceStatus.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/replaceType.ts b/packages/frontend/src/lib/server/issue/replaceType.ts deleted file mode 100644 index a3a8e56..0000000 --- a/packages/frontend/src/lib/server/issue/replaceType.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/statusCount.ts b/packages/frontend/src/lib/server/issue/statusCount.ts deleted file mode 100644 index 200e4f0..0000000 --- a/packages/frontend/src/lib/server/issue/statusCount.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/typeCount.ts b/packages/frontend/src/lib/server/issue/typeCount.ts deleted file mode 100644 index 40b887b..0000000 --- a/packages/frontend/src/lib/server/issue/typeCount.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/issue/update.ts b/packages/frontend/src/lib/server/issue/update.ts deleted file mode 100644 index c2ada65..0000000 --- a/packages/frontend/src/lib/server/issue/update.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/addMember.ts b/packages/frontend/src/lib/server/organisation/addMember.ts deleted file mode 100644 index 9750fc6..0000000 --- a/packages/frontend/src/lib/server/organisation/addMember.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/byUser.ts b/packages/frontend/src/lib/server/organisation/byUser.ts deleted file mode 100644 index b6a5d9a..0000000 --- a/packages/frontend/src/lib/server/organisation/byUser.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { OrganisationResponse } from "@sprint/shared"; -import { getServerURL } from "@/lib/utils"; -import { getErrorMessage } from ".."; - -export async function byUser(): Promise { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/create.ts b/packages/frontend/src/lib/server/organisation/create.ts deleted file mode 100644 index 15218de..0000000 --- a/packages/frontend/src/lib/server/organisation/create.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/organisation/delete.ts b/packages/frontend/src/lib/server/organisation/delete.ts deleted file mode 100644 index a4d3105..0000000 --- a/packages/frontend/src/lib/server/organisation/delete.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/index.ts b/packages/frontend/src/lib/server/organisation/index.ts deleted file mode 100644 index bd9d9c7..0000000 --- a/packages/frontend/src/lib/server/organisation/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/organisation/members.ts b/packages/frontend/src/lib/server/organisation/members.ts deleted file mode 100644 index 726e4cb..0000000 --- a/packages/frontend/src/lib/server/organisation/members.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/removeMember.ts b/packages/frontend/src/lib/server/organisation/removeMember.ts deleted file mode 100644 index 6216191..0000000 --- a/packages/frontend/src/lib/server/organisation/removeMember.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/update.ts b/packages/frontend/src/lib/server/organisation/update.ts deleted file mode 100644 index 239e5aa..0000000 --- a/packages/frontend/src/lib/server/organisation/update.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/updateMemberRole.ts b/packages/frontend/src/lib/server/organisation/updateMemberRole.ts deleted file mode 100644 index 2769eed..0000000 --- a/packages/frontend/src/lib/server/organisation/updateMemberRole.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/organisation/uploadIcon.ts b/packages/frontend/src/lib/server/organisation/uploadIcon.ts deleted file mode 100644 index da0a073..0000000 --- a/packages/frontend/src/lib/server/organisation/uploadIcon.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getCsrfToken, getServerURL } from "@/lib/utils"; -import { getErrorMessage } from ".."; - -export async function uploadIcon(file: File, organisationId: number): Promise { - 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"); -} diff --git a/packages/frontend/src/lib/server/project/byOrganisation.ts b/packages/frontend/src/lib/server/project/byOrganisation.ts deleted file mode 100644 index ffcad9d..0000000 --- a/packages/frontend/src/lib/server/project/byOrganisation.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/project/create.ts b/packages/frontend/src/lib/server/project/create.ts deleted file mode 100644 index 411aa3d..0000000 --- a/packages/frontend/src/lib/server/project/create.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/project/delete.ts b/packages/frontend/src/lib/server/project/delete.ts deleted file mode 100644 index cce9956..0000000 --- a/packages/frontend/src/lib/server/project/delete.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/project/index.ts b/packages/frontend/src/lib/server/project/index.ts deleted file mode 100644 index 985e43a..0000000 --- a/packages/frontend/src/lib/server/project/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/project/update.ts b/packages/frontend/src/lib/server/project/update.ts deleted file mode 100644 index cdb1f63..0000000 --- a/packages/frontend/src/lib/server/project/update.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/sprint/byProject.ts b/packages/frontend/src/lib/server/sprint/byProject.ts deleted file mode 100644 index e76672d..0000000 --- a/packages/frontend/src/lib/server/sprint/byProject.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/sprint/create.ts b/packages/frontend/src/lib/server/sprint/create.ts deleted file mode 100644 index 0dd3330..0000000 --- a/packages/frontend/src/lib/server/sprint/create.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/sprint/delete.ts b/packages/frontend/src/lib/server/sprint/delete.ts deleted file mode 100644 index faf0945..0000000 --- a/packages/frontend/src/lib/server/sprint/delete.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/sprint/index.ts b/packages/frontend/src/lib/server/sprint/index.ts deleted file mode 100644 index e89b9b7..0000000 --- a/packages/frontend/src/lib/server/sprint/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/sprint/update.ts b/packages/frontend/src/lib/server/sprint/update.ts deleted file mode 100644 index 3035433..0000000 --- a/packages/frontend/src/lib/server/sprint/update.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/timer/end.ts b/packages/frontend/src/lib/server/timer/end.ts deleted file mode 100644 index 3d38948..0000000 --- a/packages/frontend/src/lib/server/timer/end.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/timer/get.ts b/packages/frontend/src/lib/server/timer/get.ts deleted file mode 100644 index 18859ae..0000000 --- a/packages/frontend/src/lib/server/timer/get.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/timer/getInactive.ts b/packages/frontend/src/lib/server/timer/getInactive.ts deleted file mode 100644 index b823b83..0000000 --- a/packages/frontend/src/lib/server/timer/getInactive.ts +++ /dev/null @@ -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 { - 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 ?? []; -} diff --git a/packages/frontend/src/lib/server/timer/index.ts b/packages/frontend/src/lib/server/timer/index.ts deleted file mode 100644 index df2c99d..0000000 --- a/packages/frontend/src/lib/server/timer/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/timer/list.ts b/packages/frontend/src/lib/server/timer/list.ts deleted file mode 100644 index 79dcaa3..0000000 --- a/packages/frontend/src/lib/server/timer/list.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/timer/toggle.ts b/packages/frontend/src/lib/server/timer/toggle.ts deleted file mode 100644 index e521ed0..0000000 --- a/packages/frontend/src/lib/server/timer/toggle.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/user/byUsername.ts b/packages/frontend/src/lib/server/user/byUsername.ts deleted file mode 100644 index 64a7a43..0000000 --- a/packages/frontend/src/lib/server/user/byUsername.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/packages/frontend/src/lib/server/user/index.ts b/packages/frontend/src/lib/server/user/index.ts deleted file mode 100644 index 27bbbde..0000000 --- a/packages/frontend/src/lib/server/user/index.ts +++ /dev/null @@ -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"; diff --git a/packages/frontend/src/lib/server/user/update.ts b/packages/frontend/src/lib/server/user/update.ts deleted file mode 100644 index 180b986..0000000 --- a/packages/frontend/src/lib/server/user/update.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/packages/frontend/src/lib/server/user/uploadAvatar.ts b/packages/frontend/src/lib/server/user/uploadAvatar.ts deleted file mode 100644 index a876b21..0000000 --- a/packages/frontend/src/lib/server/user/uploadAvatar.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getCsrfToken, getServerURL } from "@/lib/utils"; -import { getErrorMessage } from ".."; - -export async function uploadAvatar(file: File): Promise { - 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"); -} diff --git a/packages/shared/package.json b/packages/shared/package.json index f632221..1afe19e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts index e8d8b64..2e6c0dc 100644 --- a/packages/shared/src/api-schemas.ts +++ b/packages/shared/src/api-schemas.ts @@ -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; +export type IssueResponse = z.infer; export const IssueCommentRecordSchema = z.object({ id: z.number(), @@ -450,18 +452,23 @@ export const IssueCommentResponseSchema = z.object({ User: UserResponseSchema, }); -export type IssueCommentResponseType = z.infer; +export type IssueCommentResponse = z.infer; 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; + 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; + export const OrganisationResponseSchema = z.object({ Organisation: OrganisationRecordSchema, OrganisationMember: OrganisationMemberRecordSchema, }); -export type OrganisationResponseType = z.infer; +export type OrganisationResponse = z.infer; + +export const OrganisationMemberResponseSchema = z.object({ + OrganisationMember: OrganisationMemberRecordSchema, + Organisation: OrganisationRecordSchema, + User: UserResponseSchema, +}); + +export type OrganisationMemberResponse = z.infer; export const ProjectRecordSchema = z.object({ id: z.number(), @@ -491,7 +508,14 @@ export const ProjectResponseSchema = z.object({ User: UserResponseSchema, }); -export type ProjectResponseType = z.infer; +export type ProjectResponse = z.infer; + +export const ProjectWithCreatorResponseSchema = z.object({ + Project: ProjectRecordSchema, + User: UserResponseSchema, +}); + +export type ProjectWithCreatorResponse = z.infer; 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; +export type SprintResponse = z.infer; export const TimerStateSchema = z .object({ diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts new file mode 100644 index 0000000..ea6b676 --- /dev/null +++ b/packages/shared/src/contract.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cface76..1b80089 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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,