Paliadin can now draft deadlines + appointments through two new owner-gated HTTP endpoints. Drafted entities land in the existing approval pipeline as approval_status='pending' with requester_kind='agent' + agent_turn_id linking back to the chat turn that produced the suggestion. The user reviews via the same eye-pill 👀 surface (with ✨ added in Slice E). POST /api/paliadin/suggest/deadline POST /api/paliadin/suggest/appointment Wiring: - ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate; always creates an approval_request (bypassing policy lookup) and stamps requester_kind='agent' + agent_turn_id. Required-role defaults to 'associate' so the deadlock check has a non-NULL threshold; m's lock-in for Q11 (every agent suggestion needs the user's eye) means bypassing the policy gate is correct here, not a regression. - The shared `submit` kernel takes an optional agent_turn_id pointer. All four lifecycle entry points (SubmitCreate / SubmitUpdate / SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes the turn id. INSERT to approval_requests now writes both requester_kind + agent_turn_id atomically (xor-check on the schema enforces consistency). - models.ApprovalRequest grows the two columns + their JSON tags so the inbox view + Verlauf renderer can read provenance without an extra fetch. - approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id to the SQL projection; both surfaces (ListPendingForApprover, ListSubmittedByUser, GetRequest) inherit the new fields free. - CreateDeadlineInput + CreateAppointmentInput each get an optional AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through SubmitAgentCreate instead of the regular SubmitCreate. Default-zero behaviour is unchanged for every existing caller. - handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via requirePaliadinOwner (same gate /paliadin uses), JSON-bodied, RFC3339 + ISO-date validation, 409 + a useful message on ErrNoQualifiedApprover. - Project-event audit metadata gains requester_kind + agent_turn_id so the project's Verlauf can render "Paliadin hat eine Frist vorgeschlagen ✨" without joining approval_requests (Slice E reads this). SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested writes" section with the tool catalog, behaviour rules ("never write directly", confirmation in the response file, project_id lookup discipline, RFC3339 dates, no chained tool calls per turn), and the 409 error contract. go build + go vet + go test all clean. No frontend changes in this slice — Slice E lights up the ✨ on existing eye-pill surfaces. Refs: docs/design-paliadin-inline-2026-05-08.md §7.
193 lines
6.7 KiB
Go
193 lines
6.7 KiB
Go
package handlers
|
|
|
|
// paliadin_suggest.go — agent-suggested write path (t-paliad-161 Slice D).
|
|
//
|
|
// HTTP endpoints invoked by Paliadin (via tools the SKILL.md describes)
|
|
// to draft an entity on the user's behalf. The drafted entity goes
|
|
// through the existing approval pipeline as approval_status='pending'
|
|
// with requester_kind='agent' + agent_turn_id, surfaced to the user via
|
|
// the eye-pill 👀 + sparkle ✨.
|
|
//
|
|
// Routes (gated to PaliadinOwnerEmail — the same gate /paliadin uses):
|
|
// POST /api/paliadin/suggest/deadline
|
|
// POST /api/paliadin/suggest/appointment
|
|
//
|
|
// Body shape (deadline):
|
|
// { "turn_id": "<uuid>", "project_id":"<uuid>", "title":"...",
|
|
// "due_date": "YYYY-MM-DD", "notes":"..." (optional),
|
|
// "rule_code": "..." (optional) }
|
|
//
|
|
// Body shape (appointment):
|
|
// { "turn_id": "<uuid>", "project_id":"<uuid>", "title":"...",
|
|
// "start_at": "RFC3339", "end_at":"RFC3339" (optional),
|
|
// "location": "..." (optional), "appointment_type":"..." (optional) }
|
|
//
|
|
// On success returns 201 + the created entity. On policy / permission
|
|
// failure, returns the same error codes the regular create paths use.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// suggestDeadlineRequest is the body POST /api/paliadin/suggest/deadline
|
|
// accepts. turn_id MUST refer to a row Paliadin actually wrote — the
|
|
// approval_requests.agent_turn_id FK rejects forged uuids.
|
|
type suggestDeadlineRequest struct {
|
|
TurnID string `json:"turn_id"`
|
|
ProjectID string `json:"project_id"`
|
|
Title string `json:"title"`
|
|
DueDate string `json:"due_date"` // YYYY-MM-DD
|
|
Notes *string `json:"notes,omitempty"`
|
|
RuleCode *string `json:"rule_code,omitempty"`
|
|
}
|
|
|
|
type suggestAppointmentRequest struct {
|
|
TurnID string `json:"turn_id"`
|
|
ProjectID string `json:"project_id"`
|
|
Title string `json:"title"`
|
|
StartAt string `json:"start_at"` // RFC3339
|
|
EndAt *string `json:"end_at,omitempty"`
|
|
Location *string `json:"location,omitempty"`
|
|
AppointmentType *string `json:"appointment_type,omitempty"`
|
|
}
|
|
|
|
// handlePaliadinSuggestDeadline drafts a deadline through the approval
|
|
// gate. Returns the created deadline (with approval_status='pending').
|
|
func handlePaliadinSuggestDeadline(w http.ResponseWriter, r *http.Request) {
|
|
if !requirePaliadinOwner(w, r) {
|
|
return
|
|
}
|
|
if dbSvc == nil || dbSvc.deadline == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "deadline service unavailable"})
|
|
return
|
|
}
|
|
uid, _ := requireUser(w, r)
|
|
|
|
var req suggestDeadlineRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
turnID, err := uuid.Parse(strings.TrimSpace(req.TurnID))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid turn_id"})
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.DueDate) == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and due_date required"})
|
|
return
|
|
}
|
|
|
|
input := services.CreateDeadlineInput{
|
|
Title: req.Title,
|
|
DueDate: req.DueDate,
|
|
Notes: req.Notes,
|
|
RuleCode: req.RuleCode,
|
|
Source: "paliadin",
|
|
AgentTurnID: &turnID,
|
|
}
|
|
d, err := dbSvc.deadline.Create(r.Context(), uid, projectID, input)
|
|
if err != nil {
|
|
mapSuggestError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, d)
|
|
}
|
|
|
|
// handlePaliadinSuggestAppointment drafts an appointment through the
|
|
// approval gate. Returns the created appointment (with
|
|
// approval_status='pending').
|
|
func handlePaliadinSuggestAppointment(w http.ResponseWriter, r *http.Request) {
|
|
if !requirePaliadinOwner(w, r) {
|
|
return
|
|
}
|
|
if dbSvc == nil || dbSvc.appointment == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "appointment service unavailable"})
|
|
return
|
|
}
|
|
uid, _ := requireUser(w, r)
|
|
|
|
var req suggestAppointmentRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
turnID, err := uuid.Parse(strings.TrimSpace(req.TurnID))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid turn_id"})
|
|
return
|
|
}
|
|
projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.StartAt) == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and start_at required"})
|
|
return
|
|
}
|
|
startAt, err := time.Parse(time.RFC3339, req.StartAt)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid start_at: " + err.Error()})
|
|
return
|
|
}
|
|
var endAt *time.Time
|
|
if req.EndAt != nil && strings.TrimSpace(*req.EndAt) != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.EndAt)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid end_at: " + err.Error()})
|
|
return
|
|
}
|
|
endAt = &t
|
|
}
|
|
|
|
input := services.CreateAppointmentInput{
|
|
ProjectID: &projectID,
|
|
Title: req.Title,
|
|
StartAt: startAt,
|
|
EndAt: endAt,
|
|
Location: req.Location,
|
|
AppointmentType: req.AppointmentType,
|
|
AgentTurnID: &turnID,
|
|
}
|
|
a, err := dbSvc.appointment.Create(r.Context(), uid, input)
|
|
if err != nil {
|
|
mapSuggestError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, a)
|
|
}
|
|
|
|
// mapSuggestError translates ApprovalService / DeadlineService errors
|
|
// into HTTP responses. Mirrors the existing approval-error mapping in
|
|
// handlers/approvals.go (mapApprovalError) but with a payload shape the
|
|
// Paliadin shim can read back into a useful chip in the response.
|
|
func mapSuggestError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, services.ErrNotVisible):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "no visibility on project"})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrNoQualifiedApprover):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"error": "no qualified approver on this project — invite an associate or higher before suggesting",
|
|
})
|
|
default:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("suggest failed: %v", err)})
|
|
}
|
|
}
|