mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 23:49:22 +02:00
Compare commits
4 Commits
fix/runtim
...
fix/batch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80956e71b6 | ||
|
|
0c2102b951 | ||
|
|
0c28d3cd08 | ||
|
|
7312b5650c |
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string {
|
||||
const DEFAULT_PATH = "/issues";
|
||||
|
||||
function createId(): string {
|
||||
return crypto.randomUUID();
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -108,7 +109,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
@@ -610,7 +611,7 @@ export class ApiClient {
|
||||
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 rid = createRequestId();
|
||||
const start = Date.now();
|
||||
this.logger.info("→ POST /api/upload-file", { rid });
|
||||
|
||||
|
||||
33
packages/core/utils.test.ts
Normal file
33
packages/core/utils.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequestId, createSafeId, generateUUID } from "./utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("utils id helpers", () => {
|
||||
it("generateUUID returns a valid UUID v4", () => {
|
||||
const id = generateUUID();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: (arr: Uint8Array) => {
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = i;
|
||||
return arr;
|
||||
},
|
||||
});
|
||||
|
||||
const id = createSafeId();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it("createRequestId defaults to length 8 and respects custom length", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
|
||||
|
||||
expect(createRequestId()).toBe("12345678");
|
||||
expect(createRequestId(12)).toBe("123456781234");
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
const cryptoObj = globalThis.crypto;
|
||||
|
||||
if (!cryptoObj?.getRandomValues) {
|
||||
throw new Error("Secure UUID generation requires crypto.getRandomValues");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
|
||||
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
|
||||
*/
|
||||
export function createSafeId(): string {
|
||||
const cryptoObj = globalThis.crypto;
|
||||
|
||||
if (cryptoObj?.randomUUID) {
|
||||
try {
|
||||
return cryptoObj.randomUUID();
|
||||
} catch {
|
||||
// Fall through to fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
/** Request id helper used for logs/tracing headers. */
|
||||
export function createRequestId(length = 8): string {
|
||||
return createSafeId().replace(/-/g, "").slice(0, length);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
|
||||
/** Find and remove a fileCard node by uploadId. */
|
||||
|
||||
@@ -109,7 +110,7 @@ export async function uploadAndInsertFile(
|
||||
}
|
||||
} else {
|
||||
// Non-image: insert skeleton fileCard → upload → finalize with real URL
|
||||
const uploadId = crypto.randomUUID();
|
||||
const uploadId = createSafeId();
|
||||
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
|
||||
const insertContent = { type: "fileCard", attrs: cardAttrs };
|
||||
if (pos !== undefined) {
|
||||
|
||||
@@ -412,7 +412,12 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Fetch user info from Google.
|
||||
userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to create userinfo request", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
|
||||
|
||||
userInfoResp, err := http.DefaultClient.Do(userInfoReq)
|
||||
|
||||
@@ -1252,6 +1252,7 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
AssigneeID: prevIssue.AssigneeID,
|
||||
DueDate: prevIssue.DueDate,
|
||||
ParentIssueID: prevIssue.ParentIssueID,
|
||||
ProjectID: prevIssue.ProjectID,
|
||||
}
|
||||
|
||||
if req.Updates.Title != nil {
|
||||
@@ -1295,6 +1296,50 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := rawUpdates["parent_issue_id"]; ok {
|
||||
if req.Updates.ParentIssueID != nil {
|
||||
newParentID := parseUUID(*req.Updates.ParentIssueID)
|
||||
// Cannot set self as parent.
|
||||
if uuidToString(newParentID) == issueID {
|
||||
continue
|
||||
}
|
||||
// Validate parent exists in the same workspace.
|
||||
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: newParentID,
|
||||
WorkspaceID: prevIssue.WorkspaceID,
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
// Cycle detection: walk up from the new parent to ensure we don't reach this issue.
|
||||
cycleDetected := false
|
||||
cursor := newParentID
|
||||
for depth := 0; depth < 10; depth++ {
|
||||
ancestor, err := h.Queries.GetIssue(r.Context(), cursor)
|
||||
if err != nil || !ancestor.ParentIssueID.Valid {
|
||||
break
|
||||
}
|
||||
if uuidToString(ancestor.ParentIssueID) == issueID {
|
||||
cycleDetected = true
|
||||
break
|
||||
}
|
||||
cursor = ancestor.ParentIssueID
|
||||
}
|
||||
if cycleDetected {
|
||||
continue
|
||||
}
|
||||
params.ParentIssueID = newParentID
|
||||
} else {
|
||||
params.ParentIssueID = pgtype.UUID{Valid: false}
|
||||
}
|
||||
}
|
||||
if _, ok := rawUpdates["project_id"]; ok {
|
||||
if req.Updates.ProjectID != nil {
|
||||
params.ProjectID = parseUUID(*req.Updates.ProjectID)
|
||||
} else {
|
||||
params.ProjectID = pgtype.UUID{Valid: false}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce agent visibility for batch assignment.
|
||||
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {
|
||||
if ok, _ := h.canAssignAgent(r.Context(), r, *req.Updates.AssigneeID, workspaceID); !ok {
|
||||
@@ -1372,11 +1417,16 @@ func (h *Handler) BatchDeleteIssues(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
|
||||
|
||||
if err := h.Queries.DeleteIssue(r.Context(), parseUUID(issueID)); err != nil {
|
||||
// Collect attachment URLs before CASCADE delete to clean up S3 objects.
|
||||
attachmentURLs, _ := h.Queries.ListAttachmentURLsByIssueOrComments(r.Context(), issue.ID)
|
||||
|
||||
if err := h.Queries.DeleteIssue(r.Context(), issue.ID); err != nil {
|
||||
slog.Warn("batch delete issue failed", "issue_id", issueID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
h.deleteS3Objects(r.Context(), attachmentURLs)
|
||||
|
||||
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
||||
h.publish(protocol.EventIssueDeleted, workspaceID, actorType, actorID, map[string]any{"issue_id": issueID})
|
||||
deleted++
|
||||
|
||||
Reference in New Issue
Block a user