mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 04:08:53 +02:00
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>
375 lines
11 KiB
TypeScript
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();
|