multica/server/internal/handler/issue.go

338 lines
9.3 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// IssueResponse is the JSON response for an issue.
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
AcceptanceCriteria []any `json:"acceptance_criteria"`
ContextRefs []any `json:"context_refs"`
Repository any `json:"repository"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func issueToResponse(i db.Issue) IssueResponse {
var ac []any
if i.AcceptanceCriteria != nil {
json.Unmarshal(i.AcceptanceCriteria, &ac)
}
if ac == nil {
ac = []any{}
}
var cr []any
if i.ContextRefs != nil {
json.Unmarshal(i.ContextRefs, &cr)
}
if cr == nil {
cr = []any{}
}
var repo any
if i.Repository != nil {
json.Unmarshal(i.Repository, &repo)
}
return IssueResponse{
ID: uuidToString(i.ID),
WorkspaceID: uuidToString(i.WorkspaceID),
Title: i.Title,
Description: textToPtr(i.Description),
Status: i.Status,
Priority: i.Priority,
AssigneeType: textToPtr(i.AssigneeType),
AssigneeID: uuidToPtr(i.AssigneeID),
CreatorType: i.CreatorType,
CreatorID: uuidToString(i.CreatorID),
ParentIssueID: uuidToPtr(i.ParentIssueID),
AcceptanceCriteria: ac,
ContextRefs: cr,
Repository: repo,
Position: i.Position,
DueDate: timestampToPtr(i.DueDate),
CreatedAt: timestampToString(i.CreatedAt),
UpdatedAt: timestampToString(i.UpdatedAt),
}
}
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
WorkspaceID: parseUUID(workspaceID),
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list issues")
return
}
resp := make([]IssueResponse, len(issues))
for i, issue := range issues {
resp[i] = issueToResponse(issue)
}
writeJSON(w, http.StatusOK, map[string]any{
"issues": resp,
"total": len(resp),
})
}
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
writeJSON(w, http.StatusOK, issueToResponse(issue))
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
AcceptanceCriteria []any `json:"acceptance_criteria"`
ContextRefs []any `json:"context_refs"`
Repository any `json:"repository"`
}
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
var req CreateIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
// Get creator from context (set by auth middleware)
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
status := req.Status
if status == "" {
status = "backlog"
}
priority := req.Priority
if priority == "" {
priority = "none"
}
ac, _ := json.Marshal(req.AcceptanceCriteria)
if req.AcceptanceCriteria == nil {
ac = []byte("[]")
}
cr, _ := json.Marshal(req.ContextRefs)
if req.ContextRefs == nil {
cr = []byte("[]")
}
var repo []byte
if req.Repository != nil {
repo, _ = json.Marshal(req.Repository)
}
var assigneeType pgtype.Text
var assigneeID pgtype.UUID
if req.AssigneeType != nil {
assigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
}
if req.AssigneeID != nil {
assigneeID = parseUUID(*req.AssigneeID)
}
var parentIssueID pgtype.UUID
if req.ParentIssueID != nil {
parentIssueID = parseUUID(*req.ParentIssueID)
}
issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
Description: ptrToText(req.Description),
Status: status,
Priority: priority,
AssigneeType: assigneeType,
AssigneeID: assigneeID,
CreatorType: "member",
CreatorID: parseUUID(creatorID),
ParentIssueID: parentIssueID,
AcceptanceCriteria: ac,
ContextRefs: cr,
Repository: repo,
Position: 0,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create issue: "+err.Error())
return
}
resp := issueToResponse(issue)
h.broadcast("issue:created", map[string]any{"issue": resp})
// Create inbox notification for assignee
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "issue_assigned",
Severity: "action_required",
IssueID: issue.ID,
Title: "New issue assigned: " + issue.Title,
Body: ptrToText(req.Description),
})
if err == nil {
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
}
}
writeJSON(w, http.StatusCreated, resp)
}
type UpdateIssueRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
}
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadIssueForUser(w, r, id); !ok {
return
}
var req UpdateIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
params := db.UpdateIssueParams{
ID: parseUUID(id),
}
if req.Title != nil {
params.Title = pgtype.Text{String: *req.Title, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.Priority != nil {
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
}
if req.AssigneeType != nil {
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
}
if req.AssigneeID != nil {
params.AssigneeID = parseUUID(*req.AssigneeID)
}
if req.Position != nil {
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
}
issue, err := h.Queries.UpdateIssue(r.Context(), params)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update issue: "+err.Error())
return
}
resp := issueToResponse(issue)
h.broadcast("issue:updated", map[string]any{"issue": resp})
// If status changed, create a notification
if req.Status != nil {
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
inboxItem, err := h.Queries.CreateInboxItem(r.Context(), db.CreateInboxItemParams{
WorkspaceID: issue.WorkspaceID,
RecipientType: issue.AssigneeType.String,
RecipientID: issue.AssigneeID,
Type: "status_change",
Severity: "info",
IssueID: issue.ID,
Title: issue.Title + " moved to " + *req.Status,
})
if err == nil {
h.broadcast("inbox:new", map[string]any{"item": inboxToResponse(inboxItem)})
}
}
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, ok := h.loadIssueForUser(w, r, id); !ok {
return
}
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete issue")
return
}
h.broadcast("issue:deleted", map[string]any{"issue_id": id})
w.WriteHeader(http.StatusNoContent)
}