/** * Issue-board transport: every call the 问题集 pages make, wrapped in the * shared envelope contract so the UI only ever sees unwrapped rows or an * `ApiFailure`. * * The generated client types paged endpoints against the generic `Page` * schema (one shape for every list), so the row type is asserted here — the * single place in the app allowed to do that, which keeps the casts out of the * components and makes a schema drift show up as one localized error. * * Issues are addressable by uuid *or* board-scoped number (`?issue=42`), so * every `issueId` parameter below accepts either form. */ import { type ApiEnvelope, toMemberUserRow, unwrapApi } from '@app/lib' import type { MemberSummaryPage } from '@app/services/api' import Apis from '@app/services/api' import type { Board, CommentNode, IssueAttachment, IssueComment, IssueDetail, IssueEvent, IssueLabel, IssuePage, IssuePatchCmd, IssueQuery, IssueState, IssueUploadIntent, Milestone, NewIssueCmd, SpaceMember, WatchMode, } from './issue' import { SORT_PARAMS } from './meta' /** * Empty strings are dropped, not sent: Alova omits `undefined` query values but * forwards `''`, and the backend's `Option` / `Option` * deserialization rejects an empty `milestone_id=` / `state=` with a 400. */ const nz = (value: T | '' | null | undefined): T | undefined => (value ? value : undefined) /** Empty selections are dropped; several become `a,b` (see the backend parser). */ const csv = (values: readonly string[] | undefined): string | undefined => values && values.length > 0 ? values.join(',') : undefined /** Board id path params repeated by almost every issue endpoint. */ export interface BoardRef { space: string board: string } const pageOf = (method: PromiseLike>): Promise> => unwrapApi | null>(method as PromiseLike | null>>).then((p) => ({ items: p?.items ?? [], total: p?.total ?? 0, limit: p?.limit ?? 0, offset: p?.offset ?? 0, })) const listOf = (method: PromiseLike>): Promise => unwrapApi(method as PromiseLike>).then((rows) => rows ?? []) const oneOf = (method: PromiseLike>): Promise => unwrapApi(method as PromiseLike>) /* ------------------------------------------------------------------ board */ export const getBoard = ({ space, board }: BoardRef) => unwrapApi(Apis.issues.get_board({ pathParams: { space_name: space, board_id: board } })) export const updateBoard = ( { space, board }: BoardRef, cmd: { name?: string; prefix?: string | null; description?: string | null; icon?: string | null }, ) => oneOf(Apis.issues.update_board({ pathParams: { space_name: space, board_id: board }, data: cmd })) export const deleteBoard = ({ space, board }: BoardRef) => unwrapApi(Apis.issues.delete_board({ pathParams: { space_name: space, board_id: board } })) /* ------------------------------------------------------------------ issue */ /** One page of issues with the board's filters applied server-side. */ export const listIssues = ({ space, board }: BoardRef, query: IssueQuery = {}) => pageOf( Apis.issues.list_issues({ pathParams: { space_name: space, board_id: board }, params: { // `all` is a UI-only choice: the backend filters "open" or "closed", and // omitting the key at all is what "no state filter" means. state: query.state && query.state !== 'all' ? (query.state as IssueState) : undefined, q: nz(query.q), // Multi-select travels as one comma-separated value: the query string // stays readable and the OpenAPI param stays a plain string. priority: csv(query.priorities), label_id: csv(query.labelIds), milestone_id: csv(query.milestoneIds), assignee_id: csv(query.assigneeIds), author_id: nz(query.authorId), sort: query.sort ? SORT_PARAMS[query.sort] : undefined, limit: query.limit, offset: query.offset ?? 0, }, }), ) export const getIssue = ({ space, board }: BoardRef, issueId: string) => unwrapApi( Apis.issues.get_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }), ) export const createIssue = ({ space, board }: BoardRef, cmd: NewIssueCmd) => oneOf( Apis.issues.create_issue({ pathParams: { space_name: space, board_id: board }, data: { title: cmd.title, description: cmd.description ?? null, content: cmd.content ?? '', priority: cmd.priority ?? 'none', due_at: cmd.due_at ?? null, milestone_id: cmd.milestone_id ?? null, assignee_ids: cmd.assignee_ids ?? [], label_ids: cmd.label_ids ?? [], attachment_ids: cmd.attachment_ids ?? [], }, }), ) export const updateIssue = ({ space, board }: BoardRef, issueId: string, cmd: IssuePatchCmd) => oneOf( Apis.issues.update_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, data: cmd, }), ) export const closeIssue = ({ space, board }: BoardRef, issueId: string) => oneOf(Apis.issues.close_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } })) export const reopenIssue = ({ space, board }: BoardRef, issueId: string) => oneOf( Apis.issues.reopen_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }), ) export const deleteIssue = ({ space, board }: BoardRef, issueId: string) => unwrapApi(Apis.issues.delete_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } })) export const setIssueWatch = ({ space, board }: BoardRef, issueId: string, mode: WatchMode) => unwrapApi( Apis.issues.upsert_issue_watch({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, data: { mode }, }), ).catch(() => null) /* ---------------------------------------------------------------- comment */ export const listCommentTree = ({ space, board }: BoardRef, issueId: string) => listOf( Apis.issues.list_comments_tree({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, }), ) export const createComment = ({ space, board }: BoardRef, issueId: string, content: string, parentId?: string) => oneOf( Apis.issues.create_comment({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, data: { content, parent_id: parentId ?? null }, }), ) export const updateComment = (ref: BoardRef, issueId: string, commentId: string, content: string) => oneOf( Apis.issues.update_comment({ pathParams: { space_name: ref.space, board_id: ref.board, issue_id: issueId, comment_id: commentId, }, data: { content }, }), ) export const deleteComment = (ref: BoardRef, issueId: string, commentId: string) => unwrapApi( Apis.issues.delete_comment({ pathParams: { space_name: ref.space, board_id: ref.board, issue_id: issueId, comment_id: commentId, }, }), ) /* ------------------------------------------------------------------ meta */ export const listEvents = ({ space, board }: BoardRef, issueId: string, limit = 50) => pageOf( Apis.issues.list_events({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, params: { limit }, }), ) export const listLabels = ({ space, board }: BoardRef) => pageOf( Apis.issues.list_labels({ pathParams: { space_name: space, board_id: board }, params: { limit: 100, is_archived: false }, }), ) export const createLabel = ( { space, board }: BoardRef, cmd: { name: string; color: string; description?: string | null }, ) => oneOf( Apis.issues.create_label({ pathParams: { space_name: space, board_id: board }, data: { name: cmd.name, color: cmd.color, description: cmd.description ?? null }, }), ) export const updateLabel = ( { space, board }: BoardRef, labelId: string, cmd: { name?: string; color?: string; description?: string | null; is_archived?: boolean }, ) => oneOf( Apis.issues.update_label({ pathParams: { space_name: space, board_id: board, label_id: labelId }, data: cmd, }), ) export const deleteLabel = ({ space, board }: BoardRef, labelId: string) => unwrapApi(Apis.issues.delete_label({ pathParams: { space_name: space, board_id: board, label_id: labelId } })) /** Labels currently bound to one issue (`GET …/issues/{id}/labels`). */ export const listIssueLabels = ({ space, board }: BoardRef, issueId: string) => listOf( Apis.issues.list_labels_for_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, }), ) export const bindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) => unwrapApi( Apis.issues.bind_label({ pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId }, }), ) export const unbindLabel = ({ space, board }: BoardRef, issueId: string, labelId: string) => unwrapApi( Apis.issues.unbind_label({ pathParams: { space_name: space, board_id: board, issue_id: issueId, label_id: labelId }, }), ) export const listMilestones = ({ space, board }: BoardRef, includeClosed = true) => pageOf( Apis.issues.list_milestones({ pathParams: { space_name: space, board_id: board }, params: { limit: 100, ...(includeClosed ? {} : { is_closed: false }) }, }), ) export const createMilestone = ( { space, board }: BoardRef, cmd: { name: string; description?: string | null; due_at?: string | null }, ) => oneOf( Apis.issues.create_milestone({ pathParams: { space_name: space, board_id: board }, data: { name: cmd.name, description: cmd.description ?? null, due_at: cmd.due_at ?? null }, }), ) export const updateMilestone = ( { space, board }: BoardRef, milestoneId: string, cmd: { name?: string; description?: string | null; due_at?: string | null; is_closed?: boolean }, ) => oneOf( Apis.issues.update_milestone({ pathParams: { space_name: space, board_id: board, milestone_id: milestoneId }, data: cmd, }), ) export const deleteMilestone = ({ space, board }: BoardRef, milestoneId: string) => unwrapApi( Apis.issues.delete_milestone({ pathParams: { space_name: space, board_id: board, milestone_id: milestoneId }, }), ) /* ---------------------------------------------------------------- members */ /** Assignable people: the space's members (issue assignees must be members). * The rows are flat `MemberSummary`s, so the identity is folded into `user`. */ export const listSpaceMembers = (space: string) => unwrapApi( Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }), ).then( (page): IssuePage => ({ items: (page?.items ?? []).map((row) => toMemberUserRow(row) as unknown as SpaceMember), total: page?.total ?? 0, limit: page?.limit ?? 0, offset: page?.offset ?? 0, }), ) /* ------------------------------------------------------------ attachments */ /** * Attachments follow the chat upload shape: reserve a row + presigned PUT, * upload the bytes straight to the object store, then finalize (which verifies * the real size/type). A draft is linked to the issue by `createIssue`'s * `attachment_ids`; only finalized drafts can be linked. */ export const createIssueAttachment = (cmd: { space_name: string filename: string content_type: string size: number }) => oneOf(Apis.issues.create_issue_attachment({ data: cmd })) export const finalizeIssueAttachment = (attachmentId: string) => oneOf( Apis.issues.finalize_issue_attachment({ pathParams: { attachment_id: attachmentId }, }), ) export const deleteIssueAttachment = (attachmentId: string) => unwrapApi(Apis.issues.delete_issue_attachment({ pathParams: { attachment_id: attachmentId } })) export const listIssueAttachments = ({ space, board }: BoardRef, issueId: string) => listOf( Apis.issues.list_issue_attachments({ pathParams: { space_name: space, board_id: board, issue_id: issueId }, }), ) /** Same-origin download/file URL for one attachment (works for `` too). */ export function issueAttachmentUrl(attachmentId: string): string { return `/api/v1/issues/attachments/${encodeURIComponent(attachmentId)}/file` } /** * PUT the raw bytes to the presigned URL. The object store allows `*` origins, * so the browser talks to it directly and a dead bucket never looks like a * dead API to the outage gate. */ export async function putIssueUpload(uploadUrl: string, file: File): Promise { const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file, }) if (!response.ok) throw new Error(`upload failed: ${response.status}`) }