- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package handlers
|
|
|
|
// workflow_assignments.go — Assignment queue for human review stages.
|
|
//
|
|
// v0.29.0: Raw SQL replaced with WorkflowStore + ChannelStore methods.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/events"
|
|
"switchboard-core/notifications"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── Workflow Assignment Handler ─────────────
|
|
|
|
type WorkflowAssignmentHandler struct {
|
|
stores store.Stores
|
|
hub *events.Hub
|
|
}
|
|
|
|
func NewWorkflowAssignmentHandler(stores store.Stores, hub ...*events.Hub) *WorkflowAssignmentHandler {
|
|
h := &WorkflowAssignmentHandler{stores: stores}
|
|
if len(hub) > 0 {
|
|
h.hub = hub[0]
|
|
}
|
|
return h
|
|
}
|
|
|
|
// ListForTeam returns unassigned + claimed assignments for a team.
|
|
// GET /api/v1/teams/:teamId/assignments
|
|
func (h *WorkflowAssignmentHandler) ListForTeam(c *gin.Context) {
|
|
teamID := c.Param("teamId")
|
|
status := c.DefaultQuery("status", "unassigned")
|
|
|
|
result, err := h.stores.Workflows.ListAssignmentsForTeam(c.Request.Context(), teamID, status)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
|
}
|
|
|
|
// ListMine returns assignments claimed by the current user plus
|
|
// unassigned assignments for teams the user belongs to.
|
|
// GET /api/v1/workflow-assignments/mine
|
|
func (h *WorkflowAssignmentHandler) ListMine(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
|
|
result, err := h.stores.Workflows.ListAssignmentsMine(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list assignments"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
|
}
|
|
|
|
// Claim assigns a workflow to the current user via optimistic lock.
|
|
// POST /api/v1/workflow-assignments/:id/claim
|
|
func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
now := time.Now().UTC()
|
|
|
|
n, err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to claim assignment"})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment already claimed or not found"})
|
|
return
|
|
}
|
|
|
|
// Look up channel for WS delivery + notification
|
|
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
|
|
|
// Emit workflow.claimed WS event to all user participants in the channel
|
|
if h.hub != nil && channelID != "" {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"assignment_id": assignmentID,
|
|
"claimed_by": userID,
|
|
"channel_id": channelID,
|
|
})
|
|
evt := events.Event{
|
|
Label: "workflow.claimed",
|
|
Payload: payload,
|
|
Ts: now.UnixMilli(),
|
|
}
|
|
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
|
for _, uid := range pids {
|
|
h.hub.PublishToUser(uid, evt)
|
|
}
|
|
}
|
|
|
|
// Persist notification for bell/inbox
|
|
if svc := notifications.Default(); svc != nil && channelID != "" {
|
|
notifications.NotifyWorkflowClaimed(svc, userID, assignmentID, channelID)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"claimed": true, "assignment_id": assignmentID})
|
|
}
|
|
|
|
// Complete marks an assignment as completed.
|
|
// POST /api/v1/workflow-assignments/:id/complete
|
|
func (h *WorkflowAssignmentHandler) Complete(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
|
|
n, err := h.stores.Workflows.CompleteAssignment(c.Request.Context(), assignmentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to complete assignment"})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"completed": true, "assignment_id": assignmentID})
|
|
}
|
|
|
|
// ── Lifecycle operations (v0.37.15) ──
|
|
|
|
// Unclaim returns a claimed assignment to unassigned.
|
|
// POST /api/v1/workflow-assignments/:id/unclaim
|
|
func (h *WorkflowAssignmentHandler) Unclaim(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
role, _ := c.Get("role")
|
|
|
|
// Auth: claimer or team admin or global admin
|
|
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
|
if err != nil || a == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
|
return
|
|
}
|
|
|
|
if role != "admin" && (a.AssignedTo == nil || *a.AssignedTo != userID) {
|
|
isTA := false
|
|
if h.stores.Teams != nil {
|
|
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
|
}
|
|
if !isTA {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "only the claimer or a team admin can unclaim"})
|
|
return
|
|
}
|
|
}
|
|
|
|
n, err := h.stores.Workflows.UnclaimAssignment(c.Request.Context(), assignmentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unclaim assignment"})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
|
return
|
|
}
|
|
|
|
// Emit WS event
|
|
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
|
if h.hub != nil && channelID != "" {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"assignment_id": assignmentID,
|
|
"unclaimed_by": userID,
|
|
"channel_id": channelID,
|
|
})
|
|
evt := events.Event{
|
|
Label: "workflow.unclaimed",
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
}
|
|
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
|
for _, uid := range pids {
|
|
h.hub.PublishToUser(uid, evt)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"unclaimed": true, "assignment_id": assignmentID})
|
|
}
|
|
|
|
// Reassign changes the assigned_to on a claimed assignment.
|
|
// POST /api/v1/workflow-assignments/:id/reassign
|
|
func (h *WorkflowAssignmentHandler) Reassign(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
role, _ := c.Get("role")
|
|
|
|
var body struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
|
return
|
|
}
|
|
|
|
// Auth: team admin or global admin
|
|
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
|
if err != nil || a == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
|
return
|
|
}
|
|
|
|
if role != "admin" {
|
|
isTA := false
|
|
if h.stores.Teams != nil {
|
|
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
|
}
|
|
if !isTA {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
|
return
|
|
}
|
|
}
|
|
|
|
n, err := h.stores.Workflows.ReassignAssignment(c.Request.Context(), assignmentID, body.UserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reassign assignment"})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in claimed state"})
|
|
return
|
|
}
|
|
|
|
// Emit WS event
|
|
channelID, _ := h.stores.Workflows.GetAssignmentChannelID(c.Request.Context(), assignmentID)
|
|
if h.hub != nil && channelID != "" {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"assignment_id": assignmentID,
|
|
"reassigned_by": userID,
|
|
"new_assignee": body.UserID,
|
|
"channel_id": channelID,
|
|
})
|
|
evt := events.Event{
|
|
Label: "workflow.reassigned",
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
}
|
|
pids, _ := h.stores.Channels.ListUserParticipantIDs(c.Request.Context(), channelID, "")
|
|
for _, uid := range pids {
|
|
h.hub.PublishToUser(uid, evt)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"reassigned": true, "assignment_id": assignmentID, "new_assignee": body.UserID})
|
|
}
|
|
|
|
// CancelAssignment cancels a single assignment.
|
|
// POST /api/v1/workflow-assignments/:id/cancel
|
|
func (h *WorkflowAssignmentHandler) CancelAssignment(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
role, _ := c.Get("role")
|
|
|
|
// Auth: team admin or global admin
|
|
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
|
if err != nil || a == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "assignment not found"})
|
|
return
|
|
}
|
|
|
|
if role != "admin" {
|
|
isTA := false
|
|
if h.stores.Teams != nil {
|
|
isTA, _ = h.stores.Teams.IsTeamAdmin(c.Request.Context(), a.TeamID, userID)
|
|
}
|
|
if !isTA {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "team admin access required"})
|
|
return
|
|
}
|
|
}
|
|
|
|
n, err := h.stores.Workflows.CancelAssignment(c.Request.Context(), assignmentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to cancel assignment"})
|
|
return
|
|
}
|
|
if n == 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "assignment not in cancellable state"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"cancelled": true, "assignment_id": assignmentID})
|
|
}
|
|
|
|
// CommentOnAssignment adds a review comment to an assignment.
|
|
// POST /api/v1/workflow-assignments/:id/comment
|
|
func (h *WorkflowAssignmentHandler) CommentOnAssignment(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil || body.Text == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "text is required"})
|
|
return
|
|
}
|
|
|
|
comment := store.ReviewComment{
|
|
Text: body.Text,
|
|
UserID: userID,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
if err := h.stores.Workflows.AddReviewComment(c.Request.Context(), assignmentID, comment); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add comment: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "comment": comment})
|
|
}
|
|
|
|
// GetAssignment returns a single assignment with review comments.
|
|
// GET /api/v1/workflow-assignments/:id
|
|
func (h *WorkflowAssignmentHandler) GetAssignment(c *gin.Context) {
|
|
assignmentID := c.Param("id")
|
|
|
|
a, err := h.stores.Workflows.GetAssignmentByID(c.Request.Context(), assignmentID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("assignment not found: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Parse review_comments for the response
|
|
var comments []store.ReviewComment
|
|
if len(a.ReviewComments) > 0 {
|
|
_ = json.Unmarshal(a.ReviewComments, &comments)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": a.ID,
|
|
"channel_id": a.ChannelID,
|
|
"stage": a.Stage,
|
|
"team_id": a.TeamID,
|
|
"assigned_to": a.AssignedTo,
|
|
"status": a.Status,
|
|
"review_comments": comments,
|
|
"created_at": a.CreatedAt,
|
|
"claimed_at": a.ClaimedAt,
|
|
"completed_at": a.CompletedAt,
|
|
})
|
|
}
|