- Add 023_edit_sessions.sql migration with unique index on (item_id, context_level, object_id) for hard interference
- Add EditSessionRepository with Acquire, Release, ReleaseForWorkstation, GetByID, ListForItem, ListForUser, TouchHeartbeat, ExpireStale, GetConflict
- Add 4 handlers: acquire (POST), release (DELETE), list by item (GET), list by user (GET)
- Acquire auto-computes dependency_cone from DAG forward cone when available
- Hard interference returns 409 with holder info (username, workstation, context_level, object_id, acquired_at)
- Publish edit.session_acquired and edit.session_released via item-scoped SSE
- Add /api/edit-sessions (user scope) and /api/items/{pn}/edit-sessions (item scope) routes
Closes #163
294 lines
9.2 KiB
Go
294 lines
9.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/kindredsystems/silo/internal/auth"
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
"github.com/kindredsystems/silo/internal/modules"
|
|
)
|
|
|
|
var validContextLevels = map[string]bool{
|
|
"sketch": true,
|
|
"partdesign": true,
|
|
"assembly": true,
|
|
}
|
|
|
|
type editSessionResponse struct {
|
|
ID string `json:"id"`
|
|
ItemID string `json:"item_id"`
|
|
PartNumber string `json:"part_number,omitempty"`
|
|
UserID string `json:"user_id"`
|
|
WorkstationID string `json:"workstation_id"`
|
|
ContextLevel string `json:"context_level"`
|
|
ObjectID *string `json:"object_id"`
|
|
DependCone []string `json:"dependency_cone"`
|
|
AcquiredAt string `json:"acquired_at"`
|
|
LastHeartbeat string `json:"last_heartbeat"`
|
|
}
|
|
|
|
func sessionToResponse(s *db.EditSession, partNumber string) editSessionResponse {
|
|
cone := s.DependencyCone
|
|
if cone == nil {
|
|
cone = []string{}
|
|
}
|
|
return editSessionResponse{
|
|
ID: s.ID,
|
|
ItemID: s.ItemID,
|
|
PartNumber: partNumber,
|
|
UserID: s.UserID,
|
|
WorkstationID: s.WorkstationID,
|
|
ContextLevel: s.ContextLevel,
|
|
ObjectID: s.ObjectID,
|
|
DependCone: cone,
|
|
AcquiredAt: s.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
LastHeartbeat: s.LastHeartbeat.UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
}
|
|
|
|
// HandleAcquireEditSession acquires an edit session on an item.
|
|
// POST /api/items/{partNumber}/edit-sessions
|
|
func (s *Server) HandleAcquireEditSession(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
user := auth.UserFromContext(ctx)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
WorkstationID string `json:"workstation_id"`
|
|
ContextLevel string `json:"context_level"`
|
|
ObjectID *string `json:"object_id"`
|
|
DependencyCone []string `json:"dependency_cone"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
|
|
return
|
|
}
|
|
if req.WorkstationID == "" {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "workstation_id is required")
|
|
return
|
|
}
|
|
if !validContextLevels[req.ContextLevel] {
|
|
writeError(w, http.StatusBadRequest, "validation_error", "context_level must be sketch, partdesign, or assembly")
|
|
return
|
|
}
|
|
|
|
// If no dependency cone provided and DAG module is enabled, attempt to compute it.
|
|
depCone := req.DependencyCone
|
|
if len(depCone) == 0 && req.ObjectID != nil && s.modules.IsEnabled(modules.DAG) {
|
|
node, nodeErr := s.dag.GetNodeByKey(ctx, item.ID, item.CurrentRevision, *req.ObjectID)
|
|
if nodeErr == nil && node != nil {
|
|
coneNodes, coneErr := s.dag.GetForwardCone(ctx, node.ID)
|
|
if coneErr == nil {
|
|
depCone = make([]string, len(coneNodes))
|
|
for i, n := range coneNodes {
|
|
depCone[i] = n.NodeKey
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
session := &db.EditSession{
|
|
ItemID: item.ID,
|
|
UserID: user.ID,
|
|
WorkstationID: req.WorkstationID,
|
|
ContextLevel: req.ContextLevel,
|
|
ObjectID: req.ObjectID,
|
|
DependencyCone: depCone,
|
|
}
|
|
|
|
if err := s.editSessions.Acquire(ctx, session); err != nil {
|
|
// Check for unique constraint violation (hard interference).
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
s.writeConflictResponse(w, r, item.ID, req.ContextLevel, req.ObjectID)
|
|
return
|
|
}
|
|
s.logger.Error().Err(err).Msg("failed to acquire edit session")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to acquire edit session")
|
|
return
|
|
}
|
|
|
|
s.broker.PublishToItem(item.ID, "edit.session_acquired", mustMarshal(map[string]any{
|
|
"session_id": session.ID,
|
|
"item_id": item.ID,
|
|
"part_number": partNumber,
|
|
"user": user.Username,
|
|
"workstation": req.WorkstationID,
|
|
"context_level": session.ContextLevel,
|
|
"object_id": session.ObjectID,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, sessionToResponse(session, partNumber))
|
|
}
|
|
|
|
// writeConflictResponse builds a 409 response with holder info.
|
|
func (s *Server) writeConflictResponse(w http.ResponseWriter, r *http.Request, itemID, contextLevel string, objectID *string) {
|
|
ctx := r.Context()
|
|
conflict, err := s.editSessions.GetConflict(ctx, itemID, contextLevel, objectID)
|
|
if err != nil || conflict == nil {
|
|
writeError(w, http.StatusConflict, "hard_interference", "Another user is editing this object")
|
|
return
|
|
}
|
|
|
|
// Look up holder's username and workstation name.
|
|
holderUser := "unknown"
|
|
if u, err := s.auth.GetUserByID(ctx, conflict.UserID); err == nil && u != nil {
|
|
holderUser = u.Username
|
|
}
|
|
holderWS := conflict.WorkstationID
|
|
if ws, err := s.workstations.GetByID(ctx, conflict.WorkstationID); err == nil && ws != nil {
|
|
holderWS = ws.Name
|
|
}
|
|
|
|
objDesc := contextLevel
|
|
if objectID != nil {
|
|
objDesc = *objectID
|
|
}
|
|
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": "hard_interference",
|
|
"holder": map[string]any{
|
|
"user": holderUser,
|
|
"workstation": holderWS,
|
|
"context_level": conflict.ContextLevel,
|
|
"object_id": conflict.ObjectID,
|
|
"acquired_at": conflict.AcquiredAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
},
|
|
"message": fmt.Sprintf("%s is currently editing %s", holderUser, objDesc),
|
|
})
|
|
}
|
|
|
|
// HandleReleaseEditSession releases an edit session.
|
|
// DELETE /api/items/{partNumber}/edit-sessions/{sessionID}
|
|
func (s *Server) HandleReleaseEditSession(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
sessionID := chi.URLParam(r, "sessionID")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
user := auth.UserFromContext(ctx)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
session, err := s.editSessions.GetByID(ctx, sessionID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to get edit session")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get edit session")
|
|
return
|
|
}
|
|
if session == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Edit session not found")
|
|
return
|
|
}
|
|
|
|
if session.UserID != user.ID && user.Role != auth.RoleAdmin {
|
|
writeError(w, http.StatusForbidden, "forbidden", "You can only release your own edit sessions")
|
|
return
|
|
}
|
|
|
|
if err := s.editSessions.Release(ctx, sessionID); err != nil {
|
|
s.logger.Error().Err(err).Str("session_id", sessionID).Msg("failed to release edit session")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to release edit session")
|
|
return
|
|
}
|
|
|
|
s.broker.PublishToItem(item.ID, "edit.session_released", mustMarshal(map[string]any{
|
|
"session_id": session.ID,
|
|
"item_id": item.ID,
|
|
"part_number": partNumber,
|
|
"user": user.Username,
|
|
"context_level": session.ContextLevel,
|
|
"object_id": session.ObjectID,
|
|
}))
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleListItemEditSessions lists active edit sessions for an item.
|
|
// GET /api/items/{partNumber}/edit-sessions
|
|
func (s *Server) HandleListItemEditSessions(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
partNumber := chi.URLParam(r, "partNumber")
|
|
|
|
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get item")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
|
return
|
|
}
|
|
if item == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
|
return
|
|
}
|
|
|
|
sessions, err := s.editSessions.ListForItem(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
|
return
|
|
}
|
|
|
|
out := make([]editSessionResponse, len(sessions))
|
|
for i, sess := range sessions {
|
|
out[i] = sessionToResponse(sess, partNumber)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// HandleListUserEditSessions lists active edit sessions for the current user.
|
|
// GET /api/edit-sessions
|
|
func (s *Server) HandleListUserEditSessions(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
user := auth.UserFromContext(ctx)
|
|
if user == nil {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
|
return
|
|
}
|
|
|
|
sessions, err := s.editSessions.ListForUser(ctx, user.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to list edit sessions")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list edit sessions")
|
|
return
|
|
}
|
|
|
|
out := make([]editSessionResponse, len(sessions))
|
|
for i, sess := range sessions {
|
|
out[i] = sessionToResponse(sess, "")
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|