feat(activity): unified activity timeline with comment reply support
Replace the comment-only list with a Linear-style unified timeline that
interleaves field changes and comments chronologically.
Backend:
- activity_listeners.go: records field changes (status, assignee, description,
task completed/failed) to activity_log table on domain events
- Timeline API: GET /api/issues/{id}/timeline merges activity_log + comments
sorted by created_at
- Comment reply: parent_id column + handler support for threading
Frontend:
- Unified timeline replaces comment list: activity entries as compact muted
lines, comments as Card components with reply threading
- Filter toggle (All / Comments / Activity)
- Reply UI: inline editor under comments with Cancel/Reply buttons
- Real-time sync for activity:created + comment events
- 10 new Go tests, all passing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3bb79564ed
commit
e7fe6ea79b
21 changed files with 1307 additions and 132 deletions
207
server/cmd/server/activity_listeners.go
Normal file
207
server/cmd/server/activity_listeners.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// registerActivityListeners wires up event bus listeners that record activity
|
||||
// entries in the activity_log table. Each listener creates one or more activity
|
||||
// records depending on what changed, then publishes an activity:created event
|
||||
// for WS broadcasting.
|
||||
func registerActivityListeners(bus *events.Bus, queries *db.Queries) {
|
||||
ctx := context.Background()
|
||||
|
||||
// issue:created — record "created" activity
|
||||
bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(issue.WorkspaceID),
|
||||
IssueID: parseUUID(issue.ID),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: "created",
|
||||
Details: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record issue created",
|
||||
"issue_id", issue.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
publishActivityEvent(bus, e, activity)
|
||||
})
|
||||
|
||||
// issue:updated — record specific changes as separate activities
|
||||
bus.Subscribe(protocol.EventIssueUpdated, func(e events.Event) {
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
statusChanged, _ := payload["status_changed"].(bool)
|
||||
assigneeChanged, _ := payload["assignee_changed"].(bool)
|
||||
descriptionChanged, _ := payload["description_changed"].(bool)
|
||||
|
||||
if statusChanged {
|
||||
prevStatus, _ := payload["prev_status"].(string)
|
||||
details, _ := json.Marshal(map[string]string{
|
||||
"from": prevStatus,
|
||||
"to": issue.Status,
|
||||
})
|
||||
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(issue.WorkspaceID),
|
||||
IssueID: parseUUID(issue.ID),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: "status_changed",
|
||||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record status change",
|
||||
"issue_id", issue.ID, "error", err)
|
||||
} else {
|
||||
publishActivityEvent(bus, e, activity)
|
||||
}
|
||||
}
|
||||
|
||||
if assigneeChanged {
|
||||
prevAssigneeType, _ := payload["prev_assignee_type"].(*string)
|
||||
prevAssigneeID, _ := payload["prev_assignee_id"].(*string)
|
||||
|
||||
detailsMap := map[string]string{}
|
||||
if prevAssigneeType != nil {
|
||||
detailsMap["from_type"] = *prevAssigneeType
|
||||
}
|
||||
if prevAssigneeID != nil {
|
||||
detailsMap["from_id"] = *prevAssigneeID
|
||||
}
|
||||
if issue.AssigneeType != nil {
|
||||
detailsMap["to_type"] = *issue.AssigneeType
|
||||
}
|
||||
if issue.AssigneeID != nil {
|
||||
detailsMap["to_id"] = *issue.AssigneeID
|
||||
}
|
||||
|
||||
details, _ := json.Marshal(detailsMap)
|
||||
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(issue.WorkspaceID),
|
||||
IssueID: parseUUID(issue.ID),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: "assignee_changed",
|
||||
Details: details,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record assignee change",
|
||||
"issue_id", issue.ID, "error", err)
|
||||
} else {
|
||||
publishActivityEvent(bus, e, activity)
|
||||
}
|
||||
}
|
||||
|
||||
if descriptionChanged {
|
||||
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(issue.WorkspaceID),
|
||||
IssueID: parseUUID(issue.ID),
|
||||
ActorType: util.StrToText(e.ActorType),
|
||||
ActorID: parseUUID(e.ActorID),
|
||||
Action: "description_updated",
|
||||
Details: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record description change",
|
||||
"issue_id", issue.ID, "error", err)
|
||||
} else {
|
||||
publishActivityEvent(bus, e, activity)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// task:completed — record "task_completed" activity
|
||||
bus.Subscribe(protocol.EventTaskCompleted, func(e events.Event) {
|
||||
handleTaskActivity(ctx, bus, queries, e, "task_completed")
|
||||
})
|
||||
|
||||
// task:failed — record "task_failed" activity
|
||||
bus.Subscribe(protocol.EventTaskFailed, func(e events.Event) {
|
||||
handleTaskActivity(ctx, bus, queries, e, "task_failed")
|
||||
})
|
||||
}
|
||||
|
||||
// handleTaskActivity records an activity for task:completed or task:failed events.
|
||||
func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Queries, e events.Event, action string) {
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
agentID, _ := payload["agent_id"].(string)
|
||||
issueID, _ := payload["issue_id"].(string)
|
||||
if issueID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Look up issue to get workspace_id
|
||||
issue, err := queries.GetIssue(ctx, parseUUID(issueID))
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to get issue for task event",
|
||||
"issue_id", issueID, "action", action, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: issue.WorkspaceID,
|
||||
IssueID: parseUUID(issueID),
|
||||
ActorType: util.StrToText("agent"),
|
||||
ActorID: parseUUID(agentID),
|
||||
Action: action,
|
||||
Details: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("activity: failed to record task activity",
|
||||
"issue_id", issueID, "action", action, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
publishActivityEvent(bus, e, activity)
|
||||
}
|
||||
|
||||
// publishActivityEvent sends an activity:created event for WS broadcasting.
|
||||
func publishActivityEvent(bus *events.Bus, original events.Event, activity db.ActivityLog) {
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventActivityCreated,
|
||||
WorkspaceID: original.WorkspaceID,
|
||||
ActorType: original.ActorType,
|
||||
ActorID: original.ActorID,
|
||||
Payload: map[string]any{
|
||||
"activity": map[string]any{
|
||||
"id": util.UUIDToString(activity.ID),
|
||||
"issue_id": util.UUIDToString(activity.IssueID),
|
||||
"actor_type": util.TextToPtr(activity.ActorType),
|
||||
"actor_id": util.UUIDToString(activity.ActorID),
|
||||
"action": activity.Action,
|
||||
"details": json.RawMessage(activity.Details),
|
||||
"created_at": util.TimestampToString(activity.CreatedAt),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
295
server/cmd/server/activity_listeners_test.go
Normal file
295
server/cmd/server/activity_listeners_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/events"
|
||||
"github.com/multica-ai/multica/server/internal/handler"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// listActivitiesForIssue is a test helper that fetches all activity_log records for an issue.
|
||||
func listActivitiesForIssue(t *testing.T, queries *db.Queries, issueID string) []db.ActivityLog {
|
||||
t.Helper()
|
||||
activities, err := queries.ListActivities(context.Background(), db.ListActivitiesParams{
|
||||
IssueID: util.ParseUUID(issueID),
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities: %v", err)
|
||||
}
|
||||
return activities
|
||||
}
|
||||
|
||||
func cleanupActivities(t *testing.T, issueID string) {
|
||||
t.Helper()
|
||||
testPool.Exec(context.Background(), `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
||||
}
|
||||
|
||||
func TestActivityIssueCreated(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueCreated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "member",
|
||||
ActorID: testUserID,
|
||||
Payload: map[string]any{
|
||||
"issue": handler.IssueResponse{
|
||||
ID: issueID,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
Title: "activity test issue",
|
||||
Status: "todo",
|
||||
Priority: "medium",
|
||||
CreatorType: "member",
|
||||
CreatorID: testUserID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Action != "created" {
|
||||
t.Fatalf("expected action 'created', got %q", activities[0].Action)
|
||||
}
|
||||
if util.UUIDToString(activities[0].ActorID) != testUserID {
|
||||
t.Fatalf("expected actor_id %s, got %s", testUserID, util.UUIDToString(activities[0].ActorID))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityIssueUpdated_StatusChanged(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueUpdated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "member",
|
||||
ActorID: testUserID,
|
||||
Payload: map[string]any{
|
||||
"issue": handler.IssueResponse{
|
||||
ID: issueID,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
Title: "activity test issue",
|
||||
Status: "in_progress",
|
||||
Priority: "medium",
|
||||
CreatorType: "member",
|
||||
CreatorID: testUserID,
|
||||
},
|
||||
"status_changed": true,
|
||||
"prev_status": "todo",
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Action != "status_changed" {
|
||||
t.Fatalf("expected action 'status_changed', got %q", activities[0].Action)
|
||||
}
|
||||
|
||||
var details map[string]string
|
||||
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
|
||||
t.Fatalf("failed to unmarshal details: %v", err)
|
||||
}
|
||||
if details["from"] != "todo" {
|
||||
t.Fatalf("expected from 'todo', got %q", details["from"])
|
||||
}
|
||||
if details["to"] != "in_progress" {
|
||||
t.Fatalf("expected to 'in_progress', got %q", details["to"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityIssueUpdated_AssigneeChanged(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
assigneeEmail := "activity-assignee-test@multica.ai"
|
||||
assigneeID := createTestUser(t, assigneeEmail)
|
||||
t.Cleanup(func() { cleanupTestUser(t, assigneeEmail) })
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
assigneeType := "member"
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueUpdated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "member",
|
||||
ActorID: testUserID,
|
||||
Payload: map[string]any{
|
||||
"issue": handler.IssueResponse{
|
||||
ID: issueID,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
Title: "activity test issue",
|
||||
Status: "todo",
|
||||
Priority: "medium",
|
||||
CreatorType: "member",
|
||||
CreatorID: testUserID,
|
||||
AssigneeType: &assigneeType,
|
||||
AssigneeID: &assigneeID,
|
||||
},
|
||||
"assignee_changed": true,
|
||||
"prev_assignee_type": (*string)(nil),
|
||||
"prev_assignee_id": (*string)(nil),
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Action != "assignee_changed" {
|
||||
t.Fatalf("expected action 'assignee_changed', got %q", activities[0].Action)
|
||||
}
|
||||
|
||||
var details map[string]string
|
||||
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
|
||||
t.Fatalf("failed to unmarshal details: %v", err)
|
||||
}
|
||||
if details["to_type"] != "member" {
|
||||
t.Fatalf("expected to_type 'member', got %q", details["to_type"])
|
||||
}
|
||||
if details["to_id"] != assigneeID {
|
||||
t.Fatalf("expected to_id %q, got %q", assigneeID, details["to_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityIssueUpdated_NoChangeFlags(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
// Publish issue:updated with no change flags set
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueUpdated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "member",
|
||||
ActorID: testUserID,
|
||||
Payload: map[string]any{
|
||||
"issue": handler.IssueResponse{
|
||||
ID: issueID,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
Title: "activity test issue",
|
||||
Status: "todo",
|
||||
Priority: "medium",
|
||||
CreatorType: "member",
|
||||
CreatorID: testUserID,
|
||||
},
|
||||
"assignee_changed": false,
|
||||
"status_changed": false,
|
||||
"description_changed": false,
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 0 {
|
||||
t.Fatalf("expected 0 activities when no change flags, got %d", len(activities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityTaskCompleted(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
agentID := testUserID // reuse as a stand-in for agent ID
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventTaskCompleted,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{
|
||||
"task_id": "00000000-0000-0000-0000-000000000001",
|
||||
"agent_id": agentID,
|
||||
"issue_id": issueID,
|
||||
"status": "completed",
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Action != "task_completed" {
|
||||
t.Fatalf("expected action 'task_completed', got %q", activities[0].Action)
|
||||
}
|
||||
if util.UUIDToString(activities[0].ActorID) != agentID {
|
||||
t.Fatalf("expected actor_id %s, got %s", agentID, util.UUIDToString(activities[0].ActorID))
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivityTaskFailed(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerActivityListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() {
|
||||
cleanupActivities(t, issueID)
|
||||
cleanupTestIssue(t, issueID)
|
||||
})
|
||||
|
||||
agentID := testUserID
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventTaskFailed,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "system",
|
||||
ActorID: "",
|
||||
Payload: map[string]any{
|
||||
"task_id": "00000000-0000-0000-0000-000000000002",
|
||||
"agent_id": agentID,
|
||||
"issue_id": issueID,
|
||||
"status": "failed",
|
||||
},
|
||||
})
|
||||
|
||||
activities := listActivitiesForIssue(t, queries, issueID)
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].Action != "task_failed" {
|
||||
t.Fatalf("expected action 'task_failed', got %q", activities[0].Action)
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ func registerListeners(bus *events.Bus, hub *realtime.Hub) {
|
|||
protocol.EventMemberRemoved,
|
||||
protocol.EventSubscriberAdded,
|
||||
protocol.EventSubscriberRemoved,
|
||||
protocol.EventActivityCreated,
|
||||
}
|
||||
|
||||
for _, et := range allEvents {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ func main() {
|
|||
// The notification listener queries the subscriber table to determine recipients,
|
||||
// so subscribers must be written first within the same synchronous event dispatch.
|
||||
registerSubscriberListeners(bus, queries)
|
||||
registerActivityListeners(bus, queries)
|
||||
registerNotificationListeners(bus, queries)
|
||||
|
||||
r := NewRouter(pool, hub, bus)
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
|||
r.Delete("/", h.DeleteIssue)
|
||||
r.Post("/comments", h.CreateComment)
|
||||
r.Get("/comments", h.ListComments)
|
||||
r.Get("/timeline", h.ListTimeline)
|
||||
r.Get("/subscribers", h.ListIssueSubscribers)
|
||||
r.Post("/subscribe", h.SubscribeToIssue)
|
||||
r.Post("/unsubscribe", h.UnsubscribeFromIssue)
|
||||
|
|
|
|||
100
server/internal/handler/activity.go
Normal file
100
server/internal/handler/activity.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// TimelineEntry represents a single entry in the issue timeline, which can be
|
||||
// either an activity log record or a comment.
|
||||
type TimelineEntry struct {
|
||||
Type string `json:"type"` // "activity" or "comment"
|
||||
ID string `json:"id"`
|
||||
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID string `json:"actor_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
// Activity-only fields
|
||||
Action *string `json:"action,omitempty"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
|
||||
// Comment-only fields
|
||||
Content *string `json:"content,omitempty"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty"`
|
||||
CommentType *string `json:"comment_type,omitempty"`
|
||||
}
|
||||
|
||||
// ListTimeline returns a merged, chronologically-sorted timeline of activities
|
||||
// and comments for a given issue.
|
||||
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
activities, err := h.Queries.ListActivities(r.Context(), db.ListActivitiesParams{
|
||||
IssueID: issue.ID,
|
||||
Limit: 200,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list activities")
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := h.Queries.ListComments(r.Context(), issue.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
||||
return
|
||||
}
|
||||
|
||||
timeline := make([]TimelineEntry, 0, len(activities)+len(comments))
|
||||
|
||||
for _, a := range activities {
|
||||
action := a.Action
|
||||
actorType := ""
|
||||
if a.ActorType.Valid {
|
||||
actorType = a.ActorType.String
|
||||
}
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
Type: "activity",
|
||||
ID: uuidToString(a.ID),
|
||||
ActorType: actorType,
|
||||
ActorID: uuidToString(a.ActorID),
|
||||
Action: &action,
|
||||
Details: a.Details,
|
||||
CreatedAt: timestampToString(a.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
for _, c := range comments {
|
||||
content := c.Content
|
||||
commentType := c.Type
|
||||
updatedAt := timestampToString(c.UpdatedAt)
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
Type: "comment",
|
||||
ID: uuidToString(c.ID),
|
||||
ActorType: c.AuthorType,
|
||||
ActorID: uuidToString(c.AuthorID),
|
||||
Content: &content,
|
||||
CommentType: &commentType,
|
||||
ParentID: uuidToPtr(c.ParentID),
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: &updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort chronologically (ascending by created_at)
|
||||
sort.Slice(timeline, func(i, j int) bool {
|
||||
return timeline[i].CreatedAt < timeline[j].CreatedAt
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, timeline)
|
||||
}
|
||||
275
server/internal/handler/activity_test.go
Normal file
275
server/internal/handler/activity_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
func TestListTimeline_MergedAndSorted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Timeline test issue",
|
||||
"status": "todo",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create an activity record directly in DB
|
||||
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
IssueID: parseUUID(issueID),
|
||||
ActorType: strToText("member"),
|
||||
ActorID: parseUUID(testUserID),
|
||||
Action: "created",
|
||||
Details: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateActivity: %v", err)
|
||||
}
|
||||
|
||||
// Create a comment
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Timeline test comment",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Fetch timeline
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.ListTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var timeline []TimelineEntry
|
||||
json.NewDecoder(w.Body).Decode(&timeline)
|
||||
if len(timeline) != 2 {
|
||||
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
|
||||
}
|
||||
|
||||
// First entry should be the activity (created earlier)
|
||||
if timeline[0].Type != "activity" {
|
||||
t.Fatalf("expected first entry type 'activity', got %q", timeline[0].Type)
|
||||
}
|
||||
if *timeline[0].Action != "created" {
|
||||
t.Fatalf("expected action 'created', got %q", *timeline[0].Action)
|
||||
}
|
||||
|
||||
// Second entry should be the comment
|
||||
if timeline[1].Type != "comment" {
|
||||
t.Fatalf("expected second entry type 'comment', got %q", timeline[1].Type)
|
||||
}
|
||||
if *timeline[1].Content != "Timeline test comment" {
|
||||
t.Fatalf("expected comment content 'Timeline test comment', got %q", *timeline[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTimeline_ChronologicalOrder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Timeline order test issue",
|
||||
"status": "todo",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create comment first
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "First comment",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
|
||||
// Then create an activity after the comment
|
||||
_, err := testHandler.Queries.CreateActivity(ctx, db.CreateActivityParams{
|
||||
WorkspaceID: parseUUID(testWorkspaceID),
|
||||
IssueID: parseUUID(issueID),
|
||||
ActorType: strToText("member"),
|
||||
ActorID: parseUUID(testUserID),
|
||||
Action: "status_changed",
|
||||
Details: []byte(`{"from":"todo","to":"in_progress"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateActivity: %v", err)
|
||||
}
|
||||
|
||||
// Fetch timeline
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.ListTimeline(w, req)
|
||||
|
||||
var timeline []TimelineEntry
|
||||
json.NewDecoder(w.Body).Decode(&timeline)
|
||||
if len(timeline) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(timeline))
|
||||
}
|
||||
|
||||
// Entries should be in chronological order
|
||||
if timeline[0].CreatedAt > timeline[1].CreatedAt {
|
||||
t.Fatalf("timeline not in chronological order: %s > %s", timeline[0].CreatedAt, timeline[1].CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateComment_WithParentID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Reply test issue",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create parent comment
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Parent comment",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment (parent): expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var parentComment CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&parentComment)
|
||||
|
||||
// Create reply with parent_id
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Reply to parent",
|
||||
"parent_id": parentComment.ID,
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateComment (reply): expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var replyComment CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&replyComment)
|
||||
|
||||
if replyComment.ParentID == nil {
|
||||
t.Fatal("expected reply to have parent_id set")
|
||||
}
|
||||
if *replyComment.ParentID != parentComment.ID {
|
||||
t.Fatalf("expected parent_id %q, got %q", parentComment.ID, *replyComment.ParentID)
|
||||
}
|
||||
|
||||
// Verify parent comment has no parent_id
|
||||
if parentComment.ParentID != nil {
|
||||
t.Fatalf("expected parent comment to have nil parent_id, got %q", *parentComment.ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentWithParentID_AppearsInTimeline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an issue
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
||||
"title": "Timeline reply test",
|
||||
})
|
||||
testHandler.CreateIssue(w, req)
|
||||
var issue IssueResponse
|
||||
json.NewDecoder(w.Body).Decode(&issue)
|
||||
issueID := issue.ID
|
||||
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
|
||||
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
|
||||
})
|
||||
|
||||
// Create parent comment
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Parent in timeline",
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
var parent CommentResponse
|
||||
json.NewDecoder(w.Body).Decode(&parent)
|
||||
|
||||
// Create reply
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
||||
"content": "Reply in timeline",
|
||||
"parent_id": parent.ID,
|
||||
})
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.CreateComment(w, req)
|
||||
|
||||
// Fetch timeline
|
||||
w = httptest.NewRecorder()
|
||||
req = newRequest("GET", "/api/issues/"+issueID+"/timeline", nil)
|
||||
req = withURLParam(req, "id", issueID)
|
||||
testHandler.ListTimeline(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ListTimeline: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var timeline []TimelineEntry
|
||||
json.NewDecoder(w.Body).Decode(&timeline)
|
||||
if len(timeline) != 2 {
|
||||
t.Fatalf("expected 2 timeline entries, got %d", len(timeline))
|
||||
}
|
||||
|
||||
// Find the reply entry
|
||||
var found bool
|
||||
for _, entry := range timeline {
|
||||
if entry.Type == "comment" && entry.ParentID != nil && *entry.ParentID == parent.ID {
|
||||
found = true
|
||||
if *entry.Content != "Reply in timeline" {
|
||||
t.Fatalf("expected reply content 'Reply in timeline', got %q", *entry.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected to find reply with parent_id in timeline")
|
||||
}
|
||||
}
|
||||
|
|
@ -6,20 +6,22 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type CommentResponse struct {
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
AuthorType string `json:"author_type"`
|
||||
AuthorID string `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func commentToResponse(c db.Comment) CommentResponse {
|
||||
|
|
@ -30,6 +32,7 @@ func commentToResponse(c db.Comment) CommentResponse {
|
|||
AuthorID: uuidToString(c.AuthorID),
|
||||
Content: c.Content,
|
||||
Type: c.Type,
|
||||
ParentID: uuidToPtr(c.ParentID),
|
||||
CreatedAt: timestampToString(c.CreatedAt),
|
||||
UpdatedAt: timestampToString(c.UpdatedAt),
|
||||
}
|
||||
|
|
@ -57,8 +60,9 @@ func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -87,12 +91,18 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
req.Type = "comment"
|
||||
}
|
||||
|
||||
var parentID pgtype.UUID
|
||||
if req.ParentID != nil {
|
||||
parentID = parseUUID(*req.ParentID)
|
||||
}
|
||||
|
||||
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
||||
IssueID: issue.ID,
|
||||
AuthorType: "member",
|
||||
AuthorID: parseUUID(userID),
|
||||
Content: req.Content,
|
||||
Type: req.Type,
|
||||
ParentID: parentID,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
|
||||
|
|
|
|||
1
server/migrations/017_comment_parent_id.down.sql
Normal file
1
server/migrations/017_comment_parent_id.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE comment DROP COLUMN parent_id;
|
||||
1
server/migrations/017_comment_parent_id.up.sql
Normal file
1
server/migrations/017_comment_parent_id.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE comment ADD COLUMN parent_id UUID REFERENCES comment(id) ON DELETE SET NULL;
|
||||
|
|
@ -12,9 +12,9 @@ import (
|
|||
)
|
||||
|
||||
const createComment = `-- name: CreateComment :one
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id
|
||||
`
|
||||
|
||||
type CreateCommentParams struct {
|
||||
|
|
@ -23,6 +23,7 @@ type CreateCommentParams struct {
|
|||
AuthorID pgtype.UUID `json:"author_id"`
|
||||
Content string `json:"content"`
|
||||
Type string `json:"type"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) {
|
||||
|
|
@ -32,6 +33,7 @@ func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (C
|
|||
arg.AuthorID,
|
||||
arg.Content,
|
||||
arg.Type,
|
||||
arg.ParentID,
|
||||
)
|
||||
var i Comment
|
||||
err := row.Scan(
|
||||
|
|
@ -43,6 +45,7 @@ func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (C
|
|||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
@ -57,7 +60,7 @@ func (q *Queries) DeleteComment(ctx context.Context, id pgtype.UUID) error {
|
|||
}
|
||||
|
||||
const getComment = `-- name: GetComment :one
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
|
|
@ -73,12 +76,13 @@ func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, erro
|
|||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listComments = `-- name: ListComments :many
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at FROM comment
|
||||
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id FROM comment
|
||||
WHERE issue_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
|
|
@ -101,6 +105,7 @@ func (q *Queries) ListComments(ctx context.Context, issueID pgtype.UUID) ([]Comm
|
|||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -117,7 +122,7 @@ UPDATE comment SET
|
|||
content = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at
|
||||
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id
|
||||
`
|
||||
|
||||
type UpdateCommentParams struct {
|
||||
|
|
@ -137,6 +142,7 @@ func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (C
|
|||
&i.Type,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ParentID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ type Comment struct {
|
|||
Type string `json:"type"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
ParentID pgtype.UUID `json:"parent_id"`
|
||||
}
|
||||
|
||||
type DaemonConnection struct {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ SELECT * FROM comment
|
|||
WHERE id = $1;
|
||||
|
||||
-- name: CreateComment :one
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO comment (issue_id, author_type, author_id, content, type, parent_id)
|
||||
VALUES ($1, $2, $3, $4, $5, sqlc.narg(parent_id))
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateComment :one
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ const (
|
|||
EventSubscriberAdded = "subscriber:added"
|
||||
EventSubscriberRemoved = "subscriber:removed"
|
||||
|
||||
// Activity events
|
||||
EventActivityCreated = "activity:created"
|
||||
|
||||
// Daemon events
|
||||
EventDaemonHeartbeat = "daemon:heartbeat"
|
||||
EventDaemonRegister = "daemon:register"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue