Feat triggers v0.2.2 (#6)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// ExtensionHandler serves extension management endpoints.
|
||||
@@ -239,6 +240,9 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.2: Sync triggers from manifest
|
||||
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkg.ID, manifestMap)
|
||||
|
||||
c.JSON(201, gin.H{"data": pkg})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/sandbox"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
|
||||
@@ -474,6 +475,9 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
// if the package declares permissions.
|
||||
SyncManifestPermissions(c, h.stores, pkgID, manifest)
|
||||
|
||||
// v0.2.2: Sync triggers from manifest
|
||||
SyncManifestTriggers(c.Request.Context(), h.stores, triggers.GlobalEngine(), pkgID, manifest)
|
||||
|
||||
// v0.30.0: Run schema migrations if declared.
|
||||
newSchemaVersion := ParseSchemaVersion(manifest)
|
||||
if newSchemaVersion > 0 && h.sandbox != nil {
|
||||
|
||||
309
server/handlers/schedules.go
Normal file
309
server/handlers/schedules.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// ScheduleHandler provides CRUD for user-created scheduled tasks.
|
||||
type ScheduleHandler struct {
|
||||
stores store.Stores
|
||||
engine *triggers.Engine
|
||||
}
|
||||
|
||||
// NewScheduleHandler creates a ScheduleHandler.
|
||||
func NewScheduleHandler(stores store.Stores, engine *triggers.Engine) *ScheduleHandler {
|
||||
return &ScheduleHandler{stores: stores, engine: engine}
|
||||
}
|
||||
|
||||
// ── User Endpoints ───────────────────────────
|
||||
|
||||
// ListMySchedules returns schedules created by the current user.
|
||||
// GET /api/v1/schedules
|
||||
func (h *ScheduleHandler) ListMySchedules(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
tasks, err := h.stores.ScheduledTasks.ListByCreator(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"schedules": tasks})
|
||||
}
|
||||
|
||||
// CreateSchedule creates a new scheduled task.
|
||||
// POST /api/v1/schedules
|
||||
func (h *ScheduleHandler) CreateSchedule(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
CronExpr string `json:"cron_expr" binding:"required"`
|
||||
Script string `json:"script"`
|
||||
TemplateID string `json:"template_id"`
|
||||
TemplateParams any `json:"template_params"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate cron expression
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(req.CronExpr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cron expression: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
nextFire := schedule.Next(time.Now())
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
task := &models.ScheduledTask{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CreatorID: userID,
|
||||
RunAs: models.RunAsCreator,
|
||||
CronExpr: req.CronExpr,
|
||||
NextFireAt: &nextFire,
|
||||
Enabled: true,
|
||||
Script: req.Script,
|
||||
TemplateID: req.TemplateID,
|
||||
}
|
||||
|
||||
if err := h.stores.ScheduledTasks.Create(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Register with engine
|
||||
h.engine.RegisterSchedule(task)
|
||||
|
||||
c.JSON(http.StatusCreated, task)
|
||||
}
|
||||
|
||||
// GetSchedule returns a single scheduled task.
|
||||
// GET /api/v1/schedules/:id
|
||||
func (h *ScheduleHandler) GetSchedule(c *gin.Context) {
|
||||
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership (non-admin can only see own schedules)
|
||||
userID := c.GetString("user_id")
|
||||
if task.CreatorID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
// UpdateSchedule updates a scheduled task.
|
||||
// PUT /api/v1/schedules/:id
|
||||
func (h *ScheduleHandler) UpdateSchedule(c *gin.Context) {
|
||||
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if task.CreatorID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
CronExpr *string `json:"cron_expr"`
|
||||
Script *string `json:"script"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
task.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
task.Description = *req.Description
|
||||
}
|
||||
if req.Script != nil {
|
||||
task.Script = *req.Script
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
task.Enabled = *req.Enabled
|
||||
}
|
||||
if req.CronExpr != nil {
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(*req.CronExpr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid cron expression: " + err.Error()})
|
||||
return
|
||||
}
|
||||
task.CronExpr = *req.CronExpr
|
||||
nextFire := schedule.Next(time.Now())
|
||||
task.NextFireAt = &nextFire
|
||||
}
|
||||
|
||||
if err := h.stores.ScheduledTasks.Update(c.Request.Context(), task); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-register with engine
|
||||
h.engine.UnregisterSchedule(task.ID)
|
||||
if task.Enabled {
|
||||
h.engine.RegisterSchedule(task)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
|
||||
// DeleteSchedule deletes a scheduled task.
|
||||
// DELETE /api/v1/schedules/:id
|
||||
func (h *ScheduleHandler) DeleteSchedule(c *gin.Context) {
|
||||
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if task.CreatorID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
h.engine.UnregisterSchedule(task.ID)
|
||||
if err := h.stores.ScheduledTasks.Delete(c.Request.Context(), task.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// RunSchedule manually triggers a scheduled task (test run).
|
||||
// POST /api/v1/schedules/:id/run
|
||||
func (h *ScheduleHandler) RunSchedule(c *gin.Context) {
|
||||
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if task.CreatorID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fire immediately in a goroutine
|
||||
go h.engine.ManualRun(task)
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"ok": true, "message": "task queued for execution"})
|
||||
}
|
||||
|
||||
// ListScheduleLogs returns execution logs for a scheduled task.
|
||||
// GET /api/v1/schedules/:id/logs
|
||||
func (h *ScheduleHandler) ListScheduleLogs(c *gin.Context) {
|
||||
task, err := h.stores.ScheduledTasks.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || task == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if task.CreatorID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parseIntParam(c, "limit", 50)
|
||||
logs, err := h.stores.Triggers.ListLogs(c.Request.Context(), task.ID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
|
||||
// ── Admin Endpoints ──────────────────────────
|
||||
|
||||
// AdminListSchedules returns all scheduled tasks (admin view).
|
||||
// GET /admin/schedules
|
||||
func (h *ScheduleHandler) AdminListSchedules(c *gin.Context) {
|
||||
opts := store.ScheduledTaskListOptions{
|
||||
ListOptions: store.ListOptions{
|
||||
Limit: parseIntParam(c, "limit", 50),
|
||||
Offset: parseIntParam(c, "offset", 0),
|
||||
},
|
||||
CreatorID: c.Query("creator_id"),
|
||||
}
|
||||
if e := c.Query("enabled"); e != "" {
|
||||
b := e == "true"
|
||||
opts.Enabled = &b
|
||||
}
|
||||
|
||||
tasks, total, err := h.stores.ScheduledTasks.List(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"schedules": tasks, "total": total})
|
||||
}
|
||||
|
||||
// AdminEnableSchedule enables a scheduled task.
|
||||
// PUT /admin/schedules/:id/enable
|
||||
func (h *ScheduleHandler) AdminEnableSchedule(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.ScheduledTasks.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
task, _ := h.stores.ScheduledTasks.GetByID(c.Request.Context(), id)
|
||||
if task != nil {
|
||||
h.engine.RegisterSchedule(task)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminDisableSchedule disables a scheduled task.
|
||||
// PUT /admin/schedules/:id/disable
|
||||
func (h *ScheduleHandler) AdminDisableSchedule(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
h.engine.UnregisterSchedule(id)
|
||||
if err := h.stores.ScheduledTasks.SetEnabled(c.Request.Context(), id, false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminDeleteSchedule deletes a scheduled task.
|
||||
// DELETE /admin/schedules/:id
|
||||
func (h *ScheduleHandler) AdminDeleteSchedule(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
h.engine.UnregisterSchedule(id)
|
||||
if err := h.stores.ScheduledTasks.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -138,6 +138,8 @@ func SeedBuiltinPackages(stores store.Stores, extensionsDir string) {
|
||||
log.Printf("⚠ Failed to update builtin package %s: %v", manifest.ID, err)
|
||||
continue
|
||||
}
|
||||
// Sync triggers on version update (v0.2.2)
|
||||
SyncManifestTriggers(ctx, stores, nil, manifest.ID, manifestMap)
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
@@ -167,6 +169,9 @@ func SeedBuiltinPackages(stores store.Stores, extensionsDir string) {
|
||||
if err := stores.Packages.Update(ctx, manifest.ID, pkg); err != nil {
|
||||
log.Printf("⚠ Failed to update builtin package metadata %s: %v", manifest.ID, err)
|
||||
}
|
||||
// Sync triggers from manifest (v0.2.2)
|
||||
SyncManifestTriggers(ctx, stores, nil, manifest.ID, manifestMap)
|
||||
|
||||
seeded++
|
||||
}
|
||||
|
||||
|
||||
192
server/handlers/trigger_sync.go
Normal file
192
server/handlers/trigger_sync.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// manifestTrigger is the manifest.json trigger declaration shape.
|
||||
type manifestTrigger struct {
|
||||
Type string `json:"type"` // "event" or "webhook"
|
||||
Pattern string `json:"pattern"` // event pattern (for type=event)
|
||||
Slug string `json:"slug"` // webhook slug (for type=webhook)
|
||||
EntryPoint string `json:"entry_point"` // Starlark function name
|
||||
}
|
||||
|
||||
// SyncManifestTriggers parses the "triggers" array from a package manifest
|
||||
// and syncs them with the trigger store. Removes triggers no longer in the
|
||||
// manifest (declarative sync). For webhooks, generates an HMAC secret on
|
||||
// first create.
|
||||
//
|
||||
// Called from SeedBuiltinPackages, admin install, and package install.
|
||||
func SyncManifestTriggers(ctx context.Context, stores store.Stores, engine *triggers.Engine, pkgID string, manifest map[string]any) {
|
||||
if stores.Triggers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse triggers from manifest
|
||||
triggersRaw, ok := manifest["triggers"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(triggersRaw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var declared []manifestTrigger
|
||||
if err := json.Unmarshal(data, &declared); err != nil {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: invalid triggers in manifest: %v", pkgID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(declared) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing triggers for this package
|
||||
existing, err := stores.Triggers.ListByPackage(ctx, pkgID)
|
||||
if err != nil {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: failed to list existing: %v", pkgID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
existingByKey := make(map[string]*models.Trigger, len(existing))
|
||||
for i := range existing {
|
||||
key := triggerKey(&existing[i])
|
||||
existingByKey[key] = &existing[i]
|
||||
}
|
||||
|
||||
declaredKeys := make(map[string]bool, len(declared))
|
||||
created, updated := 0, 0
|
||||
|
||||
for _, dt := range declared {
|
||||
if dt.Type != models.TriggerTypeEvent && dt.Type != models.TriggerTypeWebhook {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: unknown trigger type %q, skipping", pkgID, dt.Type)
|
||||
continue
|
||||
}
|
||||
if dt.EntryPoint == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := dt.Type + ":" + triggerIdentifier(dt)
|
||||
declaredKeys[key] = true
|
||||
|
||||
if ex, ok := existingByKey[key]; ok {
|
||||
// Update if entry_point changed
|
||||
if ex.EntryPoint != dt.EntryPoint {
|
||||
ex.EntryPoint = dt.EntryPoint
|
||||
if err := stores.Triggers.Update(ctx, ex); err != nil {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: update failed: %v", pkgID, err)
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new trigger
|
||||
t := &models.Trigger{
|
||||
PackageID: pkgID,
|
||||
Type: dt.Type,
|
||||
Enabled: true,
|
||||
EntryPoint: dt.EntryPoint,
|
||||
Config: json.RawMessage("{}"),
|
||||
}
|
||||
|
||||
switch dt.Type {
|
||||
case models.TriggerTypeEvent:
|
||||
t.EventPattern = dt.Pattern
|
||||
case models.TriggerTypeWebhook:
|
||||
t.Slug = dt.Slug
|
||||
t.Secret = generateSecret()
|
||||
}
|
||||
|
||||
if err := stores.Triggers.Create(ctx, t); err != nil {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: create failed: %v", pkgID, err)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
|
||||
// Register with engine
|
||||
if engine != nil {
|
||||
// Re-fetch to get the generated ID
|
||||
switch dt.Type {
|
||||
case models.TriggerTypeWebhook:
|
||||
if wh, _ := stores.Triggers.GetWebhook(ctx, pkgID, dt.Slug); wh != nil {
|
||||
engine.RegisterTrigger(wh)
|
||||
}
|
||||
case models.TriggerTypeEvent:
|
||||
// Need to fetch by package to find the new one
|
||||
triggers, _ := stores.Triggers.ListByPackage(ctx, pkgID)
|
||||
for i := range triggers {
|
||||
if triggers[i].EventPattern == dt.Pattern {
|
||||
engine.RegisterTrigger(&triggers[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove triggers no longer in manifest
|
||||
removed := 0
|
||||
for key, ex := range existingByKey {
|
||||
if !declaredKeys[key] {
|
||||
if engine != nil {
|
||||
engine.UnregisterTrigger(ex.ID)
|
||||
}
|
||||
if err := stores.Triggers.Delete(ctx, ex.ID); err != nil {
|
||||
log.Printf(" ⚠️ trigger-sync[%s]: delete failed: %v", pkgID, err)
|
||||
} else {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if created+updated+removed > 0 {
|
||||
log.Printf(" 🔔 trigger-sync[%s]: %d created, %d updated, %d removed",
|
||||
pkgID, created, updated, removed)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerKey returns a unique key for a trigger within a package.
|
||||
func triggerKey(t *models.Trigger) string {
|
||||
return t.Type + ":" + triggerIdentifierFromModel(t)
|
||||
}
|
||||
|
||||
func triggerIdentifier(dt manifestTrigger) string {
|
||||
switch dt.Type {
|
||||
case models.TriggerTypeEvent:
|
||||
return dt.Pattern
|
||||
case models.TriggerTypeWebhook:
|
||||
return dt.Slug
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func triggerIdentifierFromModel(t *models.Trigger) string {
|
||||
switch t.Type {
|
||||
case models.TriggerTypeEvent:
|
||||
return t.EventPattern
|
||||
case models.TriggerTypeWebhook:
|
||||
return t.Slug
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// generateSecret creates a random 32-byte hex-encoded secret for webhook HMAC.
|
||||
func generateSecret() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
135
server/handlers/triggers.go
Normal file
135
server/handlers/triggers.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/triggers"
|
||||
)
|
||||
|
||||
// TriggerHandler provides admin CRUD for extension-declared triggers.
|
||||
type TriggerHandler struct {
|
||||
stores store.Stores
|
||||
engine *triggers.Engine
|
||||
}
|
||||
|
||||
// NewTriggerHandler creates a TriggerHandler.
|
||||
func NewTriggerHandler(stores store.Stores, engine *triggers.Engine) *TriggerHandler {
|
||||
return &TriggerHandler{stores: stores, engine: engine}
|
||||
}
|
||||
|
||||
// ListTriggers returns all triggers with optional filters.
|
||||
// GET /admin/triggers?package_id=&type=&enabled=
|
||||
func (h *TriggerHandler) ListTriggers(c *gin.Context) {
|
||||
opts := store.TriggerListOptions{
|
||||
ListOptions: store.ListOptions{
|
||||
Limit: parseIntParam(c, "limit", 50),
|
||||
Offset: parseIntParam(c, "offset", 0),
|
||||
},
|
||||
PackageID: c.Query("package_id"),
|
||||
Type: c.Query("type"),
|
||||
}
|
||||
if e := c.Query("enabled"); e != "" {
|
||||
b := e == "true"
|
||||
opts.Enabled = &b
|
||||
}
|
||||
|
||||
triggers, total, err := h.stores.Triggers.List(c.Request.Context(), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"triggers": triggers, "total": total})
|
||||
}
|
||||
|
||||
// GetTrigger returns a single trigger by ID.
|
||||
// GET /admin/triggers/:id
|
||||
func (h *TriggerHandler) GetTrigger(c *gin.Context) {
|
||||
t, err := h.stores.Triggers.GetByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "trigger not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// EnableTrigger enables a trigger.
|
||||
// PUT /admin/triggers/:id/enable
|
||||
func (h *TriggerHandler) EnableTrigger(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Triggers.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-register with engine
|
||||
t, _ := h.stores.Triggers.GetByID(c.Request.Context(), id)
|
||||
if t != nil {
|
||||
h.engine.RegisterTrigger(t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DisableTrigger disables a trigger.
|
||||
// PUT /admin/triggers/:id/disable
|
||||
func (h *TriggerHandler) DisableTrigger(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.stores.Triggers.SetEnabled(c.Request.Context(), id, false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.engine.UnregisterTrigger(id)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// DeleteTrigger deletes a trigger.
|
||||
// DELETE /admin/triggers/:id
|
||||
func (h *TriggerHandler) DeleteTrigger(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
h.engine.UnregisterTrigger(id)
|
||||
if err := h.stores.Triggers.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// ListTriggerLogs returns execution logs for a trigger.
|
||||
// GET /admin/triggers/:id/logs?limit=
|
||||
func (h *TriggerHandler) ListTriggerLogs(c *gin.Context) {
|
||||
limit := parseIntParam(c, "limit", 50)
|
||||
logs, err := h.stores.Triggers.ListLogs(c.Request.Context(), c.Param("id"), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
|
||||
// ListPackageTriggers returns triggers for a specific package.
|
||||
// GET /admin/packages/:id/triggers
|
||||
func (h *TriggerHandler) ListPackageTriggers(c *gin.Context) {
|
||||
triggers, err := h.stores.Triggers.ListByPackage(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"triggers": triggers})
|
||||
}
|
||||
|
||||
func parseIntParam(c *gin.Context, key string, defaultVal int) int {
|
||||
if v := c.Query(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
Reference in New Issue
Block a user