Files
multica/apps/mobile/data/api.ts
Naiyuan Qing ab1fe4fa1f feat(mobile): new issue creation — Manual mode fully wired with @ mention
Mobile can now actually create issues. Phase 1 left submit as a
console.log stub; this iteration wires Manual mode end-to-end so an
issue typed on a phone lands in the backend and appears in the user's
my-issues list on next refresh.

Wire-up:
- api.createIssue(body) — POST /api/issues, mirroring server route at
  server/cmd/server/router.go:320. Matches the CreateIssueRequest type
  exported from @multica/core/types so payload shape agrees across
  clients.
- useCreateIssue() mutation in data/mutations/issues.ts — no optimistic
  insert (the my-issues list is status-bucketed + scope-filtered, so
  optimism needs bucket+scope decisions; invalidation is simpler and
  hosted-backend latency is sub-300ms). onSuccess invalidates myAll
  and inbox query keys.
- new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on
  success, surfaces errors via Alert.alert with the form state preserved
  so the user can retry. Button shows a spinner during the in-flight
  request and all inputs are disabled.

@ mention in description (members + agents):
- Mirrors comment-composer.tsx pattern exactly — selection tracking,
  tokenAtCursor on every change/selection event, MentionSuggestionBar
  rendered above the chip row, insertMention on pick, markers list
  appended.
- Title input stays plain (web doesn't allow mentions in title; we
  mirror that).
- Wire format on submit: serializeMentions(description, markers) →
  `[@name](mention://type/id)` markdown. Recognised by:
    * server/internal/util/mention.go ParseMentions
    * packages/views/editor/extensions/mention-extension.ts (web Tiptap)
    * apps/mobile/components/issue/mention-chip.tsx (mobile timeline)
- Backend does NOT trigger inbox notifications for mentions in issue
  descriptions (only on comments — see server/internal/handler/comment.go
  ParseMentions call). Mobile doesn't need to send a separate mentioned_*
  field; the markdown alone is sufficient.

Header polish:
- SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator
  in place of the ↑ glyph while pending. Defends against double-tap.
- ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle
  to match the new-issue / search modal visual reference (Linear-style).

Agent mode unchanged — still a placeholder that console.logs and
dismisses. Phase 3 will wire the real agent picker, apiClient
.quickCreateIssue, and the daemon version gate.

Explicitly NOT in this commit (later phases):
- Markdown formatting toolbar (Phase 2C)
- Project / Labels / Due date / Parent chips (Phase 2D)
- Image / file attachments (Phase 2E)
- #MUL-42 issue references, @all mention
- Draft persistence, "Create Another" toggle
- Pre-fill from sub-issue entry, optimistic list insert
- Success toast (success path = silent dismiss; mobile has no toast
  component yet)

Verified: pnpm --filter @multica/mobile typecheck passes; lint shows
only pre-existing issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:33:26 +08:00

375 lines
11 KiB
TypeScript

/**
* Mobile-owned fetch wrapper. Mirrors the surface area of
* packages/core/api/client.ts that mobile actually uses, but lives in
* apps/mobile/ so we control retry/timeout/error handling independently.
*
* Types are imported via `import type` from @multica/core/types — zero
* runtime coupling. Zod schemas + fallbacks are imported from
* @multica/core/api/schemas (pure data, on the mobile sharing whitelist).
*
* Design checklist (apps/mobile/CLAUDE.md "Lessons → ApiClient capability list"):
* 1. Zod parseWithFallback for endpoints with schemas (drift defense)
* 2. onUnauthorized callback on 401 (auto sign-out, avoids retry loops)
* 3. X-Request-ID per request + structured logger (debug + tracing)
* 4. Bearer auth + X-Workspace-Slug — NOT cookie auth (no CSRF, no credentials)
*/
import type {
Agent,
Comment,
CreateIssueRequest,
InboxItem,
Issue,
IssueLabelsResponse,
IssueReaction,
ListIssuesParams,
ListIssuesResponse,
ListLabelsResponse,
ListProjectsResponse,
MemberWithUser,
Reaction,
TimelinePage,
UpdateIssueRequest,
User,
Workspace,
} from "@multica/core/types";
import {
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_PAGE,
ListIssuesResponseSchema,
TimelinePageSchema,
} from "@multica/core/api/schemas";
import {
EMPTY_LIST_LABELS_RESPONSE,
EMPTY_LIST_PROJECTS_RESPONSE,
ListLabelsResponseSchema,
ListProjectsResponseSchema,
} from "./schemas";
import { getCurrentSlug } from "./workspace-store";
import { parseWithFallback } from "@/lib/parse-response";
import { createRequestId } from "@/lib/request-id";
const API_URL = process.env.EXPO_PUBLIC_API_URL;
if (!API_URL) {
throw new Error(
"EXPO_PUBLIC_API_URL is not set. Add it to apps/mobile/.env.development.local " +
"(see apps/mobile/.env.staging for an example).",
);
}
export interface LoginResponse {
token: string;
user: User;
}
export class ApiError extends Error {
readonly status: number;
readonly body?: unknown;
constructor(message: string, status: number, body?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
export interface ApiClientOptions {
/** Called once when the server returns 401. The platform layer wires this
* to clear the token + navigate to /login so a stale token doesn't keep
* every subsequent request looping on 401. */
onUnauthorized?: () => void;
}
class ApiClient {
private token: string | null = null;
private options: ApiClientOptions = {};
setToken(token: string | null) {
this.token = token;
}
setOptions(options: ApiClientOptions) {
this.options = { ...this.options, ...options };
}
private async fetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const rid = createRequestId();
const start = Date.now();
const method = init.method ?? "GET";
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Client-Platform": "mobile",
"X-Client-OS": "ios",
"X-Client-Version": "0.1.0",
"X-Request-ID": rid,
...((init.headers as Record<string, string>) ?? {}),
};
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`;
}
// Backend middleware (server/internal/middleware/workspace.go) resolves
// slug → ws UUID and gates membership. Mirrors packages/core/api/client.ts.
const slug = getCurrentSlug();
if (slug) {
headers["X-Workspace-Slug"] = slug;
}
console.log(`[api] → ${method} ${path}`, { rid });
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
const duration = Date.now() - start;
if (!res.ok) {
// 401 sign-out hook: invoke once, let the platform layer (auth-store)
// clear the token + navigate. Subsequent requests in flight will also
// 401 and re-enter here, so the callback must be idempotent.
if (res.status === 401) {
this.options.onUnauthorized?.();
}
let body: unknown;
try {
body = await res.json();
} catch {
body = undefined;
}
const message =
(body && typeof body === "object" && "message" in body
? String((body as { message: unknown }).message)
: null) ?? `${res.status} ${res.statusText}`;
const level = res.status === 404 ? "warn" : "error";
console[level](`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
error: message,
});
throw new ApiError(message, res.status, body);
}
console.log(`[api] ← ${res.status} ${path}`, {
rid,
duration: `${duration}ms`,
});
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// --- Auth ---
async sendCode(email: string): Promise<void> {
await this.fetch<void>("/auth/send-code", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async verifyCode(email: string, code: string): Promise<LoginResponse> {
return this.fetch<LoginResponse>("/auth/verify-code", {
method: "POST",
body: JSON.stringify({ email, code }),
});
}
async getMe(): Promise<User> {
return this.fetch<User>("/api/me");
}
// --- Workspaces ---
async listWorkspaces(): Promise<Workspace[]> {
return this.fetch<Workspace[]>("/api/workspaces");
}
// --- Inbox ---
async listInbox(): Promise<InboxItem[]> {
return this.fetch<InboxItem[]>("/api/inbox");
}
async markInboxRead(id: string): Promise<InboxItem> {
return this.fetch<InboxItem>(`/api/inbox/${id}/read`, { method: "POST" });
}
// --- Members & Agents (for actor name/avatar lookup) ---
async listMembers(workspaceId: string): Promise<MemberWithUser[]> {
return this.fetch<MemberWithUser[]>(
`/api/workspaces/${workspaceId}/members`,
);
}
async listAgents(): Promise<Agent[]> {
return this.fetch<Agent[]>("/api/agents");
}
// --- Issues ---
async listIssues(params: ListIssuesParams = {}): Promise<ListIssuesResponse> {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v == null) continue;
if (Array.isArray(v)) {
// Backend parses comma-separated lists (server/internal/handler/issue.go
// uses strings.Split on a single query value). Match web's serialization
// in packages/core/api/client.ts:407 — repeated keys would silently
// collapse to the first value only.
if (v.length > 0) search.set(k, v.map(String).join(","));
} else {
search.set(k, String(v));
}
}
const qs = search.toString();
const raw = await this.fetch<unknown>(
`/api/issues${qs ? `?${qs}` : ""}`,
);
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
endpoint: "GET /api/issues",
});
}
async getIssue(id: string): Promise<Issue> {
return this.fetch<Issue>(`/api/issues/${id}`);
}
// Write endpoint — mirrors POST /api/issues
// (server/cmd/server/router.go:320, server/internal/handler/issue.go
// CreateIssue). Mobile sends only the fields the form fills in; backend
// applies its own defaults for anything omitted.
async createIssue(body: CreateIssueRequest): Promise<Issue> {
return this.fetch<Issue>("/api/issues", {
method: "POST",
body: JSON.stringify(body),
});
}
// V1 only walks "latest → before" (oldest direction). `after` / `around`
// are not yet exposed because mobile v1 has no WS push and no notification
// deep-link landing target. Mirror of packages/core/api/client.ts
// restricted to that subset.
async listTimeline(
issueId: string,
cursor?: { mode: "before"; cursor: string } | null,
limit = 50,
): Promise<TimelinePage> {
const p = new URLSearchParams();
p.set("limit", String(limit));
if (cursor?.mode === "before") p.set("before", cursor.cursor);
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/timeline?${p.toString()}`,
);
return parseWithFallback(raw, TimelinePageSchema, EMPTY_TIMELINE_PAGE, {
endpoint: "GET /api/issues/:id/timeline",
});
}
async createComment(
issueId: string,
content: string,
parentId?: string,
): Promise<Comment> {
// Body shape mirrors backend `CreateCommentRequest`
// (server/internal/handler/comment.go:165). `parent_id` is sent only
// when present so top-level comments don't carry an explicit null.
return this.fetch<Comment>(`/api/issues/${issueId}/comments`, {
method: "POST",
body: JSON.stringify({
content,
...(parentId ? { parent_id: parentId } : {}),
}),
});
}
// --- Reactions ---
// Comment reactions: POST/DELETE /api/comments/{id}/reactions
// Issue reactions: POST/DELETE /api/issues/{id}/reactions
// Mirror surface from packages/core/api/client.ts:541-573.
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
return this.fetch<Reaction>(`/api/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeReaction(commentId: string, emoji: string): Promise<void> {
await this.fetch<void>(`/api/comments/${commentId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
async addIssueReaction(
issueId: string,
emoji: string,
): Promise<IssueReaction> {
return this.fetch<IssueReaction>(`/api/issues/${issueId}/reactions`, {
method: "POST",
body: JSON.stringify({ emoji }),
});
}
async removeIssueReaction(issueId: string, emoji: string): Promise<void> {
await this.fetch<void>(`/api/issues/${issueId}/reactions`, {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
}
// --- Issue update ---
// Write endpoint — the mutation surface handles errors via rollback, so
// we let bad responses surface naturally (no parseWithFallback).
// Method is PUT to match backend router (server/cmd/server/router.go:327)
// and web client (packages/core/api/client.ts:465).
async updateIssue(id: string, body: UpdateIssueRequest): Promise<Issue> {
return this.fetch<Issue>(`/api/issues/${id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
// --- Labels ---
async listLabels(): Promise<ListLabelsResponse> {
const raw = await this.fetch<unknown>("/api/labels");
return parseWithFallback(
raw,
ListLabelsResponseSchema,
EMPTY_LIST_LABELS_RESPONSE,
{ endpoint: "GET /api/labels" },
);
}
async attachLabel(
issueId: string,
labelId: string,
): Promise<IssueLabelsResponse> {
return this.fetch<IssueLabelsResponse>(
`/api/issues/${issueId}/labels`,
{
method: "POST",
body: JSON.stringify({ label_id: labelId }),
},
);
}
async detachLabel(
issueId: string,
labelId: string,
): Promise<IssueLabelsResponse> {
return this.fetch<IssueLabelsResponse>(
`/api/issues/${issueId}/labels/${labelId}`,
{ method: "DELETE" },
);
}
// --- Projects ---
async listProjects(): Promise<ListProjectsResponse> {
const raw = await this.fetch<unknown>("/api/projects");
return parseWithFallback(
raw,
ListProjectsResponseSchema,
EMPTY_LIST_PROJECTS_RESPONSE,
{ endpoint: "GET /api/projects" },
);
}
}
export const api = new ApiClient();