mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
fix/cloud-
...
feat/assig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c69b71eda1 |
@@ -35,6 +35,7 @@ import type {
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
ChatSession,
|
||||
@@ -259,6 +260,10 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Reaction } from "./comment";
|
||||
import type { Attachment } from "./attachment";
|
||||
|
||||
export interface AssigneeFrequencyEntry {
|
||||
assignee_type: string;
|
||||
assignee_id: string;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export interface TimelineEntry {
|
||||
type: "activity" | "comment";
|
||||
id: string;
|
||||
|
||||
@@ -25,7 +25,7 @@ export type {
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { TimelineEntry } from "./activity";
|
||||
export type { TimelineEntry, AssigneeFrequencyEntry } from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
|
||||
@@ -7,6 +7,7 @@ export const workspaceKeys = {
|
||||
members: (wsId: string) => ["workspaces", wsId, "members"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
};
|
||||
|
||||
export function workspaceListOptions() {
|
||||
@@ -37,3 +38,10 @@ export function skillListOptions(wsId: string) {
|
||||
queryFn: () => api.listSkills(),
|
||||
});
|
||||
}
|
||||
|
||||
export function assigneeFrequencyOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.assigneeFrequency(wsId),
|
||||
queryFn: () => api.getAssigneeFrequency(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +70,10 @@ vi.mock("@multica/core/workspace/queries", () => ({
|
||||
queryKey: ["workspaces", "ws-1", "agents"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
assigneeFrequencyOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "assignee-frequency"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock navigation
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
PropertyPicker,
|
||||
@@ -50,18 +50,30 @@ export function AssigneePicker({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: frequency = [] } = useQuery(assigneeFrequencyOptions(wsId));
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
||||
// Build a lookup map from frequency data for sorting.
|
||||
const freqMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const entry of frequency) {
|
||||
map.set(`${entry.assignee_type}:${entry.assignee_id}`, entry.frequency);
|
||||
}
|
||||
return map;
|
||||
}, [frequency]);
|
||||
|
||||
const getFreq = (type: string, id: string) => freqMap.get(`${type}:${id}`) ?? 0;
|
||||
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
!a.archived_at && a.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredMembers = members
|
||||
.filter((m) => m.name.toLowerCase().includes(query))
|
||||
.sort((a, b) => getFreq("member", b.user_id) - getFreq("member", a.user_id));
|
||||
const filteredAgents = agents
|
||||
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(query))
|
||||
.sort((a, b) => getFreq("agent", b.id) - getFreq("agent", a.id));
|
||||
|
||||
const isSelected = (type: string, id: string) =>
|
||||
assigneeType === type && assigneeId === id;
|
||||
|
||||
@@ -163,6 +163,9 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireWorkspaceMember(queries))
|
||||
|
||||
// Assignee frequency
|
||||
r.Get("/api/assignee-frequency", h.GetAssigneeFrequency)
|
||||
|
||||
// Issues
|
||||
r.Route("/api/issues", func(r chi.Router) {
|
||||
r.Get("/search", h.SearchIssues)
|
||||
|
||||
@@ -115,3 +115,81 @@ func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, timeline)
|
||||
}
|
||||
|
||||
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
|
||||
type AssigneeFrequencyEntry struct {
|
||||
AssigneeType string `json:"assignee_type"`
|
||||
AssigneeID string `json:"assignee_id"`
|
||||
Frequency int64 `json:"frequency"`
|
||||
}
|
||||
|
||||
// GetAssigneeFrequency returns assignee usage frequency for the current user,
|
||||
// combining data from assignee change activities and initial issue assignments.
|
||||
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
// Aggregate frequency from both data sources.
|
||||
freq := map[string]int64{} // key: "type:id"
|
||||
|
||||
// Source 1: assignee_changed activities by this user.
|
||||
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
ActorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
||||
return
|
||||
}
|
||||
for _, row := range activityCounts {
|
||||
aType, _ := row.AssigneeType.(string)
|
||||
aID, _ := row.AssigneeID.(string)
|
||||
if aType != "" && aID != "" {
|
||||
freq[aType+":"+aID] += row.Frequency
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: issues created by this user with an assignee.
|
||||
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
CreatorID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
|
||||
return
|
||||
}
|
||||
for _, row := range issueCounts {
|
||||
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
|
||||
continue
|
||||
}
|
||||
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
|
||||
freq[key] += row.Frequency
|
||||
}
|
||||
|
||||
// Build sorted response.
|
||||
result := make([]AssigneeFrequencyEntry, 0, len(freq))
|
||||
for key, count := range freq {
|
||||
// Split "type:id" — type is always "member" or "agent" (no colons).
|
||||
var aType, aID string
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == ':' {
|
||||
aType = key[:i]
|
||||
aID = key[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, AssigneeFrequencyEntry{
|
||||
AssigneeType: aType,
|
||||
AssigneeID: aID,
|
||||
Frequency: count,
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Frequency > result[j].Frequency
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,53 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countAssigneeChangesByActor = `-- name: CountAssigneeChangesByActor :many
|
||||
SELECT
|
||||
details->>'to_type' as assignee_type,
|
||||
details->>'to_id' as assignee_id,
|
||||
COUNT(*)::bigint as frequency
|
||||
FROM activity_log
|
||||
WHERE workspace_id = $1
|
||||
AND actor_id = $2
|
||||
AND actor_type = 'member'
|
||||
AND action = 'assignee_changed'
|
||||
AND details->>'to_type' IS NOT NULL
|
||||
AND details->>'to_id' IS NOT NULL
|
||||
GROUP BY details->>'to_type', details->>'to_id'
|
||||
`
|
||||
|
||||
type CountAssigneeChangesByActorParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
ActorID pgtype.UUID `json:"actor_id"`
|
||||
}
|
||||
|
||||
type CountAssigneeChangesByActorRow struct {
|
||||
AssigneeType interface{} `json:"assignee_type"`
|
||||
AssigneeID interface{} `json:"assignee_id"`
|
||||
Frequency int64 `json:"frequency"`
|
||||
}
|
||||
|
||||
// Count how many times a user assigned each target via assignee_changed activities.
|
||||
func (q *Queries) CountAssigneeChangesByActor(ctx context.Context, arg CountAssigneeChangesByActorParams) ([]CountAssigneeChangesByActorRow, error) {
|
||||
rows, err := q.db.Query(ctx, countAssigneeChangesByActor, arg.WorkspaceID, arg.ActorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []CountAssigneeChangesByActorRow{}
|
||||
for rows.Next() {
|
||||
var i CountAssigneeChangesByActorRow
|
||||
if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const createActivity = `-- name: CreateActivity :one
|
||||
INSERT INTO activity_log (
|
||||
workspace_id, issue_id, actor_type, actor_id, action, details
|
||||
|
||||
@@ -11,6 +11,52 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countCreatedIssueAssignees = `-- name: CountCreatedIssueAssignees :many
|
||||
SELECT
|
||||
assignee_type,
|
||||
assignee_id,
|
||||
COUNT(*)::bigint as frequency
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND creator_id = $2
|
||||
AND creator_type = 'member'
|
||||
AND assignee_type IS NOT NULL
|
||||
AND assignee_id IS NOT NULL
|
||||
GROUP BY assignee_type, assignee_id
|
||||
`
|
||||
|
||||
type CountCreatedIssueAssigneesParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatorID pgtype.UUID `json:"creator_id"`
|
||||
}
|
||||
|
||||
type CountCreatedIssueAssigneesRow struct {
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Frequency int64 `json:"frequency"`
|
||||
}
|
||||
|
||||
// Count assignees on issues created by a specific user.
|
||||
func (q *Queries) CountCreatedIssueAssignees(ctx context.Context, arg CountCreatedIssueAssigneesParams) ([]CountCreatedIssueAssigneesRow, error) {
|
||||
rows, err := q.db.Query(ctx, countCreatedIssueAssignees, arg.WorkspaceID, arg.CreatorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []CountCreatedIssueAssigneesRow{}
|
||||
for rows.Next() {
|
||||
var i CountCreatedIssueAssigneesRow
|
||||
if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const countIssues = `-- name: CountIssues :one
|
||||
SELECT count(*) FROM issue
|
||||
WHERE workspace_id = $1
|
||||
|
||||
@@ -9,3 +9,18 @@ INSERT INTO activity_log (
|
||||
workspace_id, issue_id, actor_type, actor_id, action, details
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CountAssigneeChangesByActor :many
|
||||
-- Count how many times a user assigned each target via assignee_changed activities.
|
||||
SELECT
|
||||
details->>'to_type' as assignee_type,
|
||||
details->>'to_id' as assignee_id,
|
||||
COUNT(*)::bigint as frequency
|
||||
FROM activity_log
|
||||
WHERE workspace_id = $1
|
||||
AND actor_id = $2
|
||||
AND actor_type = 'member'
|
||||
AND action = 'assignee_changed'
|
||||
AND details->>'to_type' IS NOT NULL
|
||||
AND details->>'to_id' IS NOT NULL
|
||||
GROUP BY details->>'to_type', details->>'to_id';
|
||||
|
||||
@@ -86,4 +86,18 @@ SELECT * FROM issue
|
||||
WHERE parent_issue_id = $1
|
||||
ORDER BY position ASC, created_at DESC;
|
||||
|
||||
-- name: CountCreatedIssueAssignees :many
|
||||
-- Count assignees on issues created by a specific user.
|
||||
SELECT
|
||||
assignee_type,
|
||||
assignee_id,
|
||||
COUNT(*)::bigint as frequency
|
||||
FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND creator_id = $2
|
||||
AND creator_type = 'member'
|
||||
AND assignee_type IS NOT NULL
|
||||
AND assignee_id IS NOT NULL
|
||||
GROUP BY assignee_type, assignee_id;
|
||||
|
||||
-- SearchIssues: moved to handler (dynamic SQL for multi-word search support).
|
||||
|
||||
Reference in New Issue
Block a user