diff --git a/packages/shared/src/api-schemas.ts b/packages/shared/src/api-schemas.ts new file mode 100644 index 0000000..b04d145 --- /dev/null +++ b/packages/shared/src/api-schemas.ts @@ -0,0 +1,430 @@ +import { z } from "zod"; +import { + ISSUE_DESCRIPTION_MAX_LENGTH, + ISSUE_STATUS_MAX_LENGTH, + ISSUE_TITLE_MAX_LENGTH, + ORG_DESCRIPTION_MAX_LENGTH, + ORG_NAME_MAX_LENGTH, + ORG_SLUG_MAX_LENGTH, + PROJECT_NAME_MAX_LENGTH, + USER_NAME_MAX_LENGTH, + USER_USERNAME_MAX_LENGTH, +} from "./constants"; + +// error response + +export const ApiErrorSchema = z.object({ + error: z.string(), + code: z.string().optional(), + details: z.record(z.array(z.string())).optional(), +}); + +export type ApiError = z.infer; + +// auth schemas + +export const LoginRequestSchema = z.object({ + username: z.string().min(1, "username is required").max(USER_USERNAME_MAX_LENGTH), + password: z.string().min(8, "password must be at least 8 characters"), +}); + +export type LoginRequest = z.infer; + +export const RegisterRequestSchema = z.object({ + name: z.string().min(1, "name is required").max(USER_NAME_MAX_LENGTH), + username: z + .string() + .min(1, "username is required") + .max(USER_USERNAME_MAX_LENGTH) + .regex(/^[a-zA-Z0-9_-]+$/, "username can only contain letters, numbers, underscores, and hyphens"), + password: z + .string() + .min(8, "password must be at least 8 characters") + .regex(/[A-Z]/, "password must contain an uppercase letter") + .regex(/[a-z]/, "password must contain a lowercase letter") + .regex(/[0-9]/, "password must contain a number"), + avatarURL: z.string().url().optional(), +}); + +export type RegisterRequest = z.infer; + +export const AuthResponseSchema = z.object({ + user: z.object({ + id: z.number(), + name: z.string(), + username: z.string(), + avatarURL: z.string().nullable(), + }), + csrfToken: z.string(), +}); + +export type AuthResponse = z.infer; + +// issue schemas + +export const IssueCreateRequestSchema = z.object({ + projectId: z.number().int().positive("projectId must be a positive integer"), + title: z.string().min(1, "title is required").max(ISSUE_TITLE_MAX_LENGTH), + description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""), + status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), + assigneeId: z.number().int().positive().nullable().optional(), + sprintId: z.number().int().positive().nullable().optional(), +}); + +export type IssueCreateRequest = z.infer; + +export const IssueUpdateRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), + title: z.string().min(1).max(ISSUE_TITLE_MAX_LENGTH).optional(), + description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(), + status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), + assigneeId: z.number().int().positive().nullable().optional(), + sprintId: z.number().int().positive().nullable().optional(), +}); + +export type IssueUpdateRequest = z.infer; + +export const IssueDeleteRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), +}); + +export type IssueDeleteRequest = z.infer; + +export const IssuesByProjectQuerySchema = z.object({ + projectId: z.coerce.number().int().positive("projectId must be a positive integer"), +}); + +export type IssuesByProjectQuery = z.infer; + +export const IssuesStatusCountQuerySchema = z.object({ + organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), + status: z.string().min(1, "status is required").max(ISSUE_STATUS_MAX_LENGTH), +}); + +export type IssuesStatusCountQuery = z.infer; + +export const IssuesReplaceStatusRequestSchema = z.object({ + organisationId: z.number().int().positive("organisationId must be a positive integer"), + oldStatus: z.string().min(1, "oldStatus is required").max(ISSUE_STATUS_MAX_LENGTH), + newStatus: z.string().min(1, "newStatus is required").max(ISSUE_STATUS_MAX_LENGTH), +}); + +export type IssuesReplaceStatusRequest = z.infer; + +// organisation schemas + +export const OrgCreateRequestSchema = z.object({ + name: z.string().min(1, "name is required").max(ORG_NAME_MAX_LENGTH), + slug: z + .string() + .min(1, "slug is required") + .max(ORG_SLUG_MAX_LENGTH) + .regex(/^[a-z0-9-]+$/, "slug can only contain lowercase letters, numbers, and hyphens"), + description: z.string().max(ORG_DESCRIPTION_MAX_LENGTH).optional(), +}); + +export type OrgCreateRequest = z.infer; + +export const OrgUpdateRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), + name: z.string().min(1).max(ORG_NAME_MAX_LENGTH).optional(), + description: z.string().max(ORG_DESCRIPTION_MAX_LENGTH).optional(), + slug: z + .string() + .min(1) + .max(ORG_SLUG_MAX_LENGTH) + .regex(/^[a-z0-9-]+$/) + .optional(), + statuses: z + .record(z.string()) + .refine((obj) => Object.keys(obj).length > 0, "statuses must have at least one entry") + .refine( + (obj) => Object.keys(obj).every((key) => key.length <= ISSUE_STATUS_MAX_LENGTH), + `status keys must be <= ${ISSUE_STATUS_MAX_LENGTH} characters`, + ) + .optional(), +}); + +export type OrgUpdateRequest = z.infer; + +export const OrgDeleteRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), +}); + +export type OrgDeleteRequest = z.infer; + +export const OrgByIdQuerySchema = z.object({ + id: z.coerce.number().int().positive("id must be a positive integer"), +}); + +export type OrgByIdQuery = z.infer; + +export const OrgMembersQuerySchema = z.object({ + organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), +}); + +export type OrgMembersQuery = z.infer; + +export const OrgAddMemberRequestSchema = z.object({ + organisationId: z.number().int().positive("organisationId must be a positive integer"), + userId: z.number().int().positive("userId must be a positive integer"), + role: z.enum(["admin", "member"]).default("member"), +}); + +export type OrgAddMemberRequest = z.infer; + +export const OrgRemoveMemberRequestSchema = z.object({ + organisationId: z.number().int().positive("organisationId must be a positive integer"), + userId: z.number().int().positive("userId must be a positive integer"), +}); + +export type OrgRemoveMemberRequest = z.infer; + +export const OrgUpdateMemberRoleRequestSchema = z.object({ + organisationId: z.number().int().positive("organisationId must be a positive integer"), + userId: z.number().int().positive("userId must be a positive integer"), + role: z.enum(["admin", "member"]), +}); + +export type OrgUpdateMemberRoleRequest = z.infer; + +// project schemas + +export const ProjectCreateRequestSchema = z.object({ + name: z.string().min(1, "name is required").max(PROJECT_NAME_MAX_LENGTH), + key: z + .string() + .length(4, "key must be exactly 4 characters") + .regex(/^[A-Z]{4}$/, "key must be 4 uppercase letters"), + organisationId: z.number().int().positive("organisationId must be a positive integer"), +}); + +export type ProjectCreateRequest = z.infer; + +export const ProjectUpdateRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), + name: z.string().min(1).max(PROJECT_NAME_MAX_LENGTH).optional(), + key: z + .string() + .length(4) + .regex(/^[A-Z]{4}$/) + .optional(), + creatorId: z.number().int().positive().optional(), + organisationId: z.number().int().positive().optional(), +}); + +export type ProjectUpdateRequest = z.infer; + +export const ProjectDeleteRequestSchema = z.object({ + id: z.number().int().positive("id must be a positive integer"), +}); + +export type ProjectDeleteRequest = z.infer; + +export const ProjectByOrgQuerySchema = z.object({ + organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), +}); + +export type ProjectByOrgQuery = z.infer; + +export const ProjectByIdQuerySchema = z.object({ + id: z.coerce.number().int().positive("id must be a positive integer"), +}); + +export type ProjectByIdQuery = z.infer; + +export const ProjectByCreatorQuerySchema = z.object({ + creatorId: z.coerce.number().int().positive("creatorId must be a positive integer"), +}); + +export type ProjectByCreatorQuery = z.infer; + +// sprint schemas + +export const SprintCreateRequestSchema = z + .object({ + projectId: z.number().int().positive("projectId must be a positive integer"), + name: z.string().min(1, "name is required").max(64), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, "color must be a valid hex color") + .optional(), + startDate: z.string().datetime("startDate must be a valid ISO datetime"), + endDate: z.string().datetime("endDate must be a valid ISO datetime"), + }) + .refine((data) => new Date(data.startDate) <= new Date(data.endDate), { + message: "endDate must be after startDate", + path: ["endDate"], + }); + +export type SprintCreateRequest = z.infer; + +export const SprintsByProjectQuerySchema = z.object({ + projectId: z.coerce.number().int().positive("projectId must be a positive integer"), +}); + +export type SprintsByProjectQuery = z.infer; + +// timer schemas + +export const TimerToggleRequestSchema = z.object({ + issueId: z.number().int().positive("issueId must be a positive integer"), +}); + +export type TimerToggleRequest = z.infer; + +export const TimerEndRequestSchema = z.object({ + issueId: z.number().int().positive("issueId must be a positive integer"), +}); + +export type TimerEndRequest = z.infer; + +export const TimerGetQuerySchema = z.object({ + issueId: z.coerce.number().int().positive("issueId must be a positive integer"), +}); + +export type TimerGetQuery = z.infer; + +// user schemas + +export const UserUpdateRequestSchema = z.object({ + name: z.string().min(1).max(USER_NAME_MAX_LENGTH).optional(), + password: z + .string() + .min(8) + .regex(/[A-Z]/, "password must contain an uppercase letter") + .regex(/[a-z]/, "password must contain a lowercase letter") + .regex(/[0-9]/, "password must contain a number") + .optional(), + avatarURL: z.string().url().nullable().optional(), +}); + +export type UserUpdateRequest = z.infer; + +export const UserByUsernameQuerySchema = z.object({ + username: z.string().min(1, "username is required"), +}); + +export type UserByUsernameQuery = z.infer; + +// response schemas + +export const UserResponseSchema = z.object({ + id: z.number(), + name: z.string(), + username: z.string(), + avatarURL: z.string().nullable(), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export type UserResponse = z.infer; + +export const IssueRecordSchema = z.object({ + id: z.number(), + projectId: z.number(), + number: z.number(), + title: z.string(), + description: z.string(), + status: z.string(), + creatorId: z.number(), + assigneeId: z.number().nullable(), + sprintId: z.number().nullable(), +}); + +export const IssueResponseSchema = z.object({ + Issue: IssueRecordSchema, + Creator: UserResponseSchema, + Assignee: UserResponseSchema.nullable(), +}); + +export type IssueResponseType = z.infer; + +export const OrganisationRecordSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string().nullable(), + slug: z.string(), + statuses: z.record(z.string()), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), +}); + +export const OrganisationMemberRecordSchema = z.object({ + id: z.number(), + organisationId: z.number(), + userId: z.number(), + role: z.string(), + createdAt: z.string().nullable().optional(), +}); + +export const OrganisationResponseSchema = z.object({ + Organisation: OrganisationRecordSchema, + OrganisationMember: OrganisationMemberRecordSchema, +}); + +export type OrganisationResponseType = z.infer; + +export const ProjectRecordSchema = z.object({ + id: z.number(), + key: z.string(), + name: z.string(), + organisationId: z.number(), + creatorId: z.number(), +}); + +export const ProjectResponseSchema = z.object({ + Project: ProjectRecordSchema, + Organisation: OrganisationRecordSchema, + User: UserResponseSchema, +}); + +export type ProjectResponseType = z.infer; + +export const SprintRecordSchema = z.object({ + id: z.number(), + projectId: z.number(), + name: z.string(), + color: z.string(), + startDate: z.string(), + endDate: z.string(), + createdAt: z.string().nullable().optional(), +}); + +export type SprintResponseType = z.infer; + +export const TimerStateSchema = z + .object({ + id: z.number(), + workTimeMs: z.number(), + breakTimeMs: z.number(), + isRunning: z.boolean(), + timestamps: z.array(z.string()), + endedAt: z.string().nullable(), + }) + .nullable(); + +export type TimerStateType = z.infer; + +export const StatusCountResponseSchema = z.array( + z.object({ + status: z.string(), + count: z.number(), + }), +); + +export type StatusCountResponse = z.infer; + +export const ReplaceStatusResponseSchema = z.object({ + rowCount: z.number(), +}); + +export type ReplaceStatusResponse = z.infer; + +// general + +export const SuccessResponseSchema = z.object({ + success: z.boolean(), +}); + +export type SuccessResponse = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 505e5d4..37ae9f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,92 @@ +export type { + ApiError, + AuthResponse, + IssueCreateRequest, + IssueDeleteRequest, + IssueResponseType, + IssuesByProjectQuery, + IssuesReplaceStatusRequest, + IssuesStatusCountQuery, + IssueUpdateRequest, + LoginRequest, + OrgAddMemberRequest, + OrganisationResponseType, + OrgByIdQuery, + OrgCreateRequest, + OrgDeleteRequest, + OrgMembersQuery, + OrgRemoveMemberRequest, + OrgUpdateMemberRoleRequest, + OrgUpdateRequest, + ProjectByCreatorQuery, + ProjectByIdQuery, + ProjectByOrgQuery, + ProjectCreateRequest, + ProjectDeleteRequest, + ProjectResponseType, + ProjectUpdateRequest, + RegisterRequest, + ReplaceStatusResponse, + SprintCreateRequest, + SprintResponseType, + SprintsByProjectQuery, + StatusCountResponse, + SuccessResponse, + TimerEndRequest, + TimerGetQuery, + TimerStateType, + TimerToggleRequest, + UserByUsernameQuery, + UserResponse, + UserUpdateRequest, +} from "./api-schemas"; +// API schemas +export { + ApiErrorSchema, + AuthResponseSchema, + IssueCreateRequestSchema, + IssueDeleteRequestSchema, + IssueRecordSchema, + IssueResponseSchema, + IssuesByProjectQuerySchema, + IssuesReplaceStatusRequestSchema, + IssuesStatusCountQuerySchema, + IssueUpdateRequestSchema, + LoginRequestSchema, + OrgAddMemberRequestSchema, + OrganisationMemberRecordSchema, + OrganisationRecordSchema, + OrganisationResponseSchema, + OrgByIdQuerySchema, + OrgCreateRequestSchema, + OrgDeleteRequestSchema, + OrgMembersQuerySchema, + OrgRemoveMemberRequestSchema, + OrgUpdateMemberRoleRequestSchema, + OrgUpdateRequestSchema, + ProjectByCreatorQuerySchema, + ProjectByIdQuerySchema, + ProjectByOrgQuerySchema, + ProjectCreateRequestSchema, + ProjectDeleteRequestSchema, + ProjectRecordSchema, + ProjectResponseSchema, + ProjectUpdateRequestSchema, + RegisterRequestSchema, + ReplaceStatusResponseSchema, + SprintCreateRequestSchema, + SprintRecordSchema, + SprintsByProjectQuerySchema, + StatusCountResponseSchema, + SuccessResponseSchema, + TimerEndRequestSchema, + TimerGetQuerySchema, + TimerStateSchema, + TimerToggleRequestSchema, + UserByUsernameQuerySchema, + UserResponseSchema, + UserUpdateRequestSchema, +} from "./api-schemas"; export { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH, @@ -63,5 +152,4 @@ export { UserInsertSchema, UserSelectSchema, } from "./schema"; - export { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "./utils/time-tracking";