main
/**
* 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<Uuid>` / `Option<IssueState>`
* deserialization rejects an empty `milestone_id=` / `state=` with a 400.
*/
const nz = <T>(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 = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<IssuePage<T>> =>
unwrapApi<IssuePage<T> | null>(method as PromiseLike<ApiEnvelope<IssuePage<T> | null>>).then((p) => ({
items: p?.items ?? [],
total: p?.total ?? 0,
limit: p?.limit ?? 0,
offset: p?.offset ?? 0,
}))
const listOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T[]> =>
unwrapApi<T[] | null>(method as PromiseLike<ApiEnvelope<T[] | null>>).then((rows) => rows ?? [])
const oneOf = <T>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
/* ------------------------------------------------------------------ board */
export const getBoard = ({ space, board }: BoardRef) =>
unwrapApi<Board>(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<Board>(Apis.issues.update_board({ pathParams: { space_name: space, board_id: board }, data: cmd }))
export const deleteBoard = ({ space, board }: BoardRef) =>
unwrapApi<null>(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<import('./issue').IssueListItem>(
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<IssueDetail>(
Apis.issues.get_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
)
export const createIssue = ({ space, board }: BoardRef, cmd: NewIssueCmd) =>
oneOf<IssueDetail>(
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<IssueDetail>(
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<IssueDetail>(Apis.issues.close_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }))
export const reopenIssue = ({ space, board }: BoardRef, issueId: string) =>
oneOf<IssueDetail>(
Apis.issues.reopen_issue({ pathParams: { space_name: space, board_id: board, issue_id: issueId } }),
)
export const deleteIssue = ({ space, board }: BoardRef, issueId: string) =>
unwrapApi<null>(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<unknown>(
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<CommentNode>(
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<IssueComment>(
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<IssueComment>(
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<null>(
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<IssueEvent>(
Apis.issues.list_events({
pathParams: { space_name: space, board_id: board, issue_id: issueId },
params: { limit },
}),
)
export const listLabels = ({ space, board }: BoardRef) =>
pageOf<IssueLabel>(
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<IssueLabel>(
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<IssueLabel>(
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<null>(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<IssueLabel>(
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<null>(
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<null>(
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<Milestone>(
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<Milestone>(
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<Milestone>(
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<null>(
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<MemberSummaryPage | null>(
Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
).then(
(page): IssuePage<SpaceMember> => ({
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<IssueUploadIntent>(Apis.issues.create_issue_attachment({ data: cmd }))
export const finalizeIssueAttachment = (attachmentId: string) =>
oneOf<IssueAttachment>(
Apis.issues.finalize_issue_attachment({
pathParams: { attachment_id: attachmentId },
}),
)
export const deleteIssueAttachment = (attachmentId: string) =>
unwrapApi<null>(Apis.issues.delete_issue_attachment({ pathParams: { attachment_id: attachmentId } }))
export const listIssueAttachments = ({ space, board }: BoardRef, issueId: string) =>
listOf<IssueAttachment>(
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 `<img>` 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<void> {
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}`)
}