import { z } from "zod"; import { ISSUE_COMMENT_MAX_LENGTH, ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH, ISSUE_TYPE_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"; import { DEFAULT_FEATURES } from "./schema"; // 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(1, "Password is required"), }); 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().nullable().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(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]), }), 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"), type: z.string().max(ISSUE_TYPE_MAX_LENGTH).optional(), status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), title: z.string().min(1, "Title is required").max(ISSUE_TITLE_MAX_LENGTH), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""), assigneeIds: z.array(z.number().int().positive()).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"), type: z.string().max(ISSUE_TYPE_MAX_LENGTH).optional(), status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(), title: z.string().min(1, "Title must be at least 1 character").max(ISSUE_TITLE_MAX_LENGTH).optional(), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(), assigneeIds: z.array(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 IssueByIdQuerySchema = z.object({ issueId: z.coerce.number().int().positive("issueId must be a positive integer"), }); export type IssueByIdQuery = 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; export const IssuesTypeCountQuerySchema = z.object({ organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), type: z.string().min(1, "Type is required").max(ISSUE_TYPE_MAX_LENGTH), }); export type IssuesTypeCountQuery = z.infer; export const IssuesReplaceTypeRequestSchema = z.object({ organisationId: z.number().int().positive("organisationId must be a positive integer"), oldType: z.string().min(1, "oldType is required").max(ISSUE_TYPE_MAX_LENGTH), newType: z.string().min(1, "newType is required").max(ISSUE_TYPE_MAX_LENGTH), }); export type IssuesReplaceTypeRequest = z.infer; export const IssueCommentCreateRequestSchema = z.object({ issueId: z.number().int().positive("issueId must be a positive integer"), body: z.string().min(1, "Comment is required").max(ISSUE_COMMENT_MAX_LENGTH), }); export type IssueCommentCreateRequest = z.infer; export const IssueCommentDeleteRequestSchema = z.object({ id: z.number().int().positive("id must be a positive integer"), }); export type IssueCommentDeleteRequest = z.infer; export const IssueCommentsByIssueQuerySchema = z.object({ issueId: z.coerce.number().int().positive("issueId must be a positive integer"), }); export type IssueCommentsByIssueQuery = 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, "Name must be at least 1 character").max(ORG_NAME_MAX_LENGTH).optional(), description: z.string().max(ORG_DESCRIPTION_MAX_LENGTH).optional(), slug: z .string() .min(1, "Slug must be at least 1 character") .max(ORG_SLUG_MAX_LENGTH) .regex(/^[a-z0-9-]+$/) .optional(), iconURL: z.string().url().max(512).nullable().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(), features: z .record(z.boolean()) .refine( (obj) => Object.keys(obj).length === Object.keys(DEFAULT_FEATURES).length, "Features must include all default features", ) .optional(), issueTypes: z .record(z.object({ icon: z.string(), color: z.string() })) .refine((obj) => Object.keys(obj).length > 0, "Issue types must have at least one entry") .refine( (obj) => Object.keys(obj).every((key) => key.length <= ISSUE_TYPE_MAX_LENGTH), `Issue type keys must be <= ${ISSUE_TYPE_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 OrgMemberTimeTrackingQuerySchema = z.object({ organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"), fromDate: z.coerce.date().optional(), }); export type OrgMemberTimeTrackingQuery = 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() .min(1, "Key is required") .max(4, "Key must be 4 characters or less") .regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z"), 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, "Name must be at least 1 character").max(PROJECT_NAME_MAX_LENGTH).optional(), key: z .string() .min(1, "Key is required") .max(4, "Key must be 4 characters or less") .regex(/^[A-Za-z]{1,4}$/, "Key must be only letters A-Z") .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, "Name must be at most 64 characters"), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Color must be a valid hex color") .optional(), startDate: z.string().datetime("Start date must be a valid date"), endDate: z.string().datetime("End date must be a valid date"), }) .refine((data) => new Date(data.startDate) <= new Date(data.endDate), { message: "End date must be after start date", path: ["endDate"], }); export type SprintCreateRequest = z.infer; export const SprintUpdateRequestSchema = z .object({ id: z.number().int().positive("id must be a positive integer"), name: z.string().min(1, "Name is required").max(64, "Name must be at most 64 characters").optional(), color: z .string() .regex(/^#[0-9a-fA-F]{6}$/, "Color must be a valid hex color") .optional(), startDate: z.string().datetime("Start date must be a valid date").optional(), endDate: z.string().datetime("End date must be a valid date").optional(), }) .refine( (data) => { if (data.startDate && data.endDate) { return new Date(data.startDate) <= new Date(data.endDate); } return true; }, { message: "End date must be after start date", path: ["endDate"], }, ); export type SprintUpdateRequest = z.infer; export const SprintDeleteRequestSchema = z.object({ id: z.number().int().positive("id must be a positive integer"), }); export type SprintDeleteRequest = 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, "Name must be at least 1 character").max(USER_NAME_MAX_LENGTH).optional(), 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") .optional(), avatarURL: z.string().url().nullable().optional(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]).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(), iconPreference: z.enum(["lucide", "pixel", "phosphor"]), plan: z.string().nullable().optional(), 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(), type: z.string(), status: z.string(), title: z.string(), description: z.string(), creatorId: z.number(), sprintId: z.number().nullable(), }); export const IssueResponseSchema = z.object({ Issue: IssueRecordSchema, Creator: UserResponseSchema, Assignees: z.array(UserResponseSchema), }); export type IssueResponse = z.infer; export const IssueCommentRecordSchema = z.object({ id: z.number(), issueId: z.number(), userId: z.number(), body: z.string(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }); export const IssueCommentResponseSchema = z.object({ Comment: IssueCommentRecordSchema, User: UserResponseSchema, }); 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(), userId: z.number(), role: z.string(), createdAt: z.string().nullable().optional(), }); export type OrganisationMemberRecordType = z.infer; export const OrganisationResponseSchema = z.object({ Organisation: OrganisationRecordSchema, OrganisationMember: OrganisationMemberRecordSchema, }); 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(), 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 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(), projectId: z.number(), name: z.string(), color: z.string(), startDate: z.string(), endDate: z.string(), createdAt: z.string().nullable().optional(), }); export type SprintResponse = 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 TimerListItemSchema = z.object({ id: z.number(), issueId: z.number(), issueNumber: z.number(), projectKey: z.string(), workTimeMs: z.number(), breakTimeMs: z.number(), isRunning: z.boolean(), timestamps: z.array(z.string()), endedAt: z.string().nullable(), }); export type TimerListItem = z.infer; export const TimerListResponseSchema = z.array(TimerListItemSchema); export type TimerListResponse = 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; export const TypeCountResponseSchema = z.object({ count: z.number(), }); export type TypeCountResponse = z.infer; export const ReplaceTypeResponseSchema = z.object({ rowCount: z.number(), }); export type ReplaceTypeResponse = z.infer; // general export const SuccessResponseSchema = z.object({ success: z.boolean(), }); export type SuccessResponse = z.infer; // subscription schemas export const CreateCheckoutSessionRequestSchema = z.object({ billingPeriod: z.enum(["monthly", "annual"]), }); export type CreateCheckoutSessionRequest = z.infer; export const CreateCheckoutSessionResponseSchema = z.object({ url: z.string(), }); export type CreateCheckoutSessionResponse = z.infer; export const CreatePortalSessionResponseSchema = z.object({ url: z.string(), }); export type CreatePortalSessionResponse = z.infer; export const SubscriptionRecordSchema = z.object({ id: z.number(), userId: z.number(), stripeCustomerId: z.string().nullable(), stripeSubscriptionId: z.string().nullable(), stripeSubscriptionItemId: z.string().nullable(), stripePriceId: z.string().nullable(), status: z.string(), currentPeriodStart: z.string().nullable().optional(), currentPeriodEnd: z.string().nullable().optional(), cancelAtPeriodEnd: z.boolean(), trialEnd: z.string().nullable().optional(), quantity: z.number(), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }); export type SubscriptionRecord = z.infer; export const GetSubscriptionResponseSchema = z.object({ subscription: SubscriptionRecordSchema.nullable(), }); export type GetSubscriptionResponse = z.infer; export const CancelSubscriptionResponseSchema = z.object({ subscription: SubscriptionRecordSchema, }); export type CancelSubscriptionResponse = z.infer;