replaced per-endpoint helpers with ts-rest contract and typed client

This commit is contained in:
2026-01-28 13:01:28 +00:00
parent aa24de2e8e
commit d6af2032db
71 changed files with 1042 additions and 1075 deletions

View File

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

View File

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

View File

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

View File

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