merge: resolve conflicts with main
This commit is contained in:
commit
4780540bd2
121 changed files with 6937 additions and 1556 deletions
|
|
@ -12,8 +12,6 @@ import type {
|
|||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentRuntime,
|
||||
DaemonPairingSession,
|
||||
ApproveDaemonPairingSessionRequest,
|
||||
InboxItem,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
|
|
@ -35,6 +33,7 @@ import type {
|
|||
RuntimePing,
|
||||
TimelineEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
} from "@/shared/types";
|
||||
import { type Logger, noopLogger } from "@/shared/logger";
|
||||
|
||||
|
|
@ -62,6 +61,35 @@ export class ApiClient {
|
|||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
private handleUnauthorized() {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async parseErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (typeof data.error === "string" && data.error) return data.error;
|
||||
} catch {
|
||||
// Ignore non-JSON error bodies.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const start = Date.now();
|
||||
|
|
@ -70,42 +98,21 @@ export class ApiClient {
|
|||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": rid,
|
||||
...this.authHeaders(),
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`;
|
||||
}
|
||||
if (this.workspaceId) {
|
||||
headers["X-Workspace-ID"] = this.workspaceId;
|
||||
}
|
||||
|
||||
this.logger.info(`→ ${method} ${path}`, { rid });
|
||||
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
this.token = null;
|
||||
this.workspaceId = null;
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
||||
let message = `API error: ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (typeof data.error === "string" && data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON error bodies.
|
||||
}
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
this.logger.error(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
|
@ -202,13 +209,14 @@ export class ApiClient {
|
|||
return this.fetch(`/api/issues/${issueId}/comments`);
|
||||
}
|
||||
|
||||
async createComment(issueId: string, content: string, type?: string, parentId?: string): Promise<Comment> {
|
||||
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
return this.fetch(`/api/issues/${issueId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
type: type ?? "comment",
|
||||
...(parentId ? { parent_id: parentId } : {}),
|
||||
...(attachmentIds?.length ? { attachment_ids: attachmentIds } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -358,20 +366,6 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async getDaemonPairingSession(token: string): Promise<DaemonPairingSession> {
|
||||
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
|
||||
}
|
||||
|
||||
async approveDaemonPairingSession(
|
||||
token: string,
|
||||
data: ApproveDaemonPairingSessionRequest,
|
||||
): Promise<DaemonPairingSession> {
|
||||
return this.fetch(`/api/daemon/pairing-sessions/${token}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
|
|
@ -525,4 +519,41 @@ export class ApiClient {
|
|||
async revokePersonalAccessToken(id: string): Promise<void> {
|
||||
await this.fetch(`/api/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// File Upload & Attachments
|
||||
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||||
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const start = Date.now();
|
||||
this.logger.info("→ POST /api/upload-file", { rid });
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/upload-file`, {
|
||||
method: "POST",
|
||||
headers: this.authHeaders(),
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) this.handleUnauthorized();
|
||||
const message = await this.parseErrorMessage(res, `Upload failed: ${res.status}`);
|
||||
this.logger.error(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||||
return res.json() as Promise<Attachment>;
|
||||
}
|
||||
|
||||
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export { ApiClient } from "./client";
|
|||
export type { LoginResponse } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL, { logger: createLogger("api") });
|
||||
|
||||
|
|
|
|||
84
apps/web/shared/hooks/use-file-upload.ts
Normal file
84
apps/web/shared/hooks/use-file-upload.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import type { Attachment } from "@/shared/types";
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/json",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"application/zip",
|
||||
]);
|
||||
|
||||
function isAllowedType(type: string): boolean {
|
||||
// Empty MIME type (browser couldn't determine) — let the server sniff and decide.
|
||||
if (!type) return true;
|
||||
const mediaType = type.split(";")[0] ?? "";
|
||||
return ALLOWED_TYPES.has(mediaType.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
commentId?: string;
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new Error("File exceeds 10 MB limit");
|
||||
}
|
||||
if (!isAllowedType(file.type)) {
|
||||
throw new Error(`File type not allowed: ${file.type}`);
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const att: Attachment = await api.uploadFile(file, {
|
||||
issueId: ctx?.issueId,
|
||||
commentId: ctx?.commentId,
|
||||
});
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const uploadWithToast = useCallback(
|
||||
async (file: File, ctx?: UploadContext): Promise<UploadResult | null> => {
|
||||
try {
|
||||
return await upload(file, ctx);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed");
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[upload],
|
||||
);
|
||||
|
||||
return { upload, uploadWithToast, uploading };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Reaction } from "./comment";
|
||||
import type { Attachment } from "./attachment";
|
||||
|
||||
export interface TimelineEntry {
|
||||
type: "activity" | "comment";
|
||||
|
|
@ -15,4 +16,5 @@ export interface TimelineEntry {
|
|||
updated_at?: string;
|
||||
comment_type?: string;
|
||||
reactions?: Reaction[];
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
|
|
|||
14
apps/web/shared/types/attachment.ts
Normal file
14
apps/web/shared/types/attachment.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export interface Attachment {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
issue_id: string | null;
|
||||
comment_id: string | null;
|
||||
uploader_type: string;
|
||||
uploader_id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
download_url: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export interface Comment {
|
|||
type: CommentType;
|
||||
parent_id: string | null;
|
||||
reactions: Reaction[];
|
||||
attachments: import("./attachment").Attachment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
export type DaemonPairingSessionStatus = "pending" | "approved" | "claimed" | "expired";
|
||||
|
||||
export interface DaemonPairingSession {
|
||||
token: string;
|
||||
daemon_id: string;
|
||||
device_name: string;
|
||||
runtime_name: string;
|
||||
runtime_type: string;
|
||||
runtime_version: string;
|
||||
workspace_id: string | null;
|
||||
status: DaemonPairingSessionStatus;
|
||||
approved_at: string | null;
|
||||
claimed_at: string | null;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
link_url?: string | null;
|
||||
}
|
||||
|
||||
export interface ApproveDaemonPairingSessionRequest {
|
||||
workspace_id: string;
|
||||
}
|
||||
|
|
@ -27,6 +27,6 @@ export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
|||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { TimelineEntry } from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
export type { DaemonPairingSession, DaemonPairingSessionStatus, ApproveDaemonPairingSessionRequest } from "./daemon";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue