Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions backend/automation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package main

import (
"encoding/json"
"strings"

plugin "github.com/Paca-AI/plugin-sdk-go"
)

// This file implements the automation-graph Condition and Action node
// types this plugin contributes, registered in Init via ctx.Condition and
// ctx.Action. The corresponding Trigger (time_logging.entry_created,
// emitted from createTimeLog in timelogs.go) needs no handler here — the
// engine matches triggers purely on event topic, per AutomationManifest's
// EventTopic field (see domain/plugin/entity.go in the core).
//
// Node types are namespaced under the plugin's short name, "time_logging"
// (the last dot-separated segment of the plugin ID "com.paca.time-logging",
// snake_cased), not the full reverse-DNS ID — matching the "automation"
// block in plugin.json.

const (
// automationConditionTotalMinutesExceeds checks whether the sum of
// minutes logged on a task (optionally filtered to a single member)
// exceeds a configured threshold.
automationConditionTotalMinutesExceeds = "time_logging.total_minutes_exceeds"

// automationActionLogTime creates a time-log entry on the task, the
// same operation createTimeLog performs over HTTP.
automationActionLogTime = "time_logging.log_time"
)

// registerAutomationNodes wires this plugin's Condition/Action handlers
// into ctx. Called once from Init.
func (p *timeLoggingPlugin) registerAutomationNodes(ctx *plugin.Context) {
ctx.Condition(automationConditionTotalMinutesExceeds, p.conditionTotalMinutesExceeds)
ctx.Action(automationActionLogTime, p.actionLogTime)
}

// ─── Condition: time_logging.total_minutes_exceeds ────────────────────────────

func (p *timeLoggingPlugin) conditionTotalMinutesExceeds(req *plugin.ConditionRequest) plugin.ConditionResult {
var cfg struct {
ThresholdMinutes int `json:"threshold_minutes"`
MemberID string `json:"member_id"` // optional: filter to one member; empty = every member
}
if err := json.Unmarshal(req.Config, &cfg); err != nil || cfg.ThresholdMinutes <= 0 {
p.log.Error("time-logging: total_minutes_exceeds condition: invalid config")
return plugin.ConditionResult{Matched: false}
}

// Summed in Go rather than via SQL SUM/COALESCE: functionally identical
// against the real Postgres backend, but also portable to the
// plugintest in-memory DB used in this plugin's own test suite, which
// only supports simple column-projection + WHERE matching (see
// plugin-sdk-go/plugintest/backends.go), not aggregate functions.
var (
result *plugin.DBQueryResult
err error
)
if cfg.MemberID != "" {
result, err = p.db.Query(
`SELECT minutes_spent FROM task_time_logs WHERE task_id = $1 AND member_id = $2`,
req.Task.ID, cfg.MemberID,
)
} else {
result, err = p.db.Query(
`SELECT minutes_spent FROM task_time_logs WHERE task_id = $1`,
req.Task.ID,
)
}
if err != nil {
p.log.Error("time-logging: total_minutes_exceeds condition: query failed: " + err.Error())
return plugin.ConditionResult{Matched: false}
}

total := 0
for _, row := range result.Rows {
total += newRowScanner(result.Columns, row).intVal("minutes_spent")
}
return plugin.ConditionResult{Matched: total > cfg.ThresholdMinutes}
}

// ─── Action: time_logging.log_time ────────────────────────────────────────────

func (p *timeLoggingPlugin) actionLogTime(req *plugin.ActionRequest) plugin.ActionResult {
var cfg struct {
MemberID string `json:"member_id"`
SpentDate string `json:"spent_date"`
MinutesSpent int `json:"minutes_spent"`
Note string `json:"note"`
}
if err := json.Unmarshal(req.Config, &cfg); err != nil {
return plugin.ActionResult{Applied: false, Error: "invalid config"}
}
if cfg.MemberID == "" {
return plugin.ActionResult{Applied: false, Error: "config.member_id is required"}
}
if cfg.SpentDate == "" {
return plugin.ActionResult{Applied: false, Error: "config.spent_date is required"}
}
if cfg.MinutesSpent <= 0 {
return plugin.ActionResult{Applied: false, Error: "config.minutes_spent must be greater than zero"}
}

// Idempotency: a plugin action can be retried by the automation
// engine, so treat a prior run having already logged this exact
// idempotency key as an already-applied no-op rather than
// double-logging time. The key is stored in the note's trailing
// metadata since the schema has no dedicated column for it. Fetched
// and scanned in Go (rather than via a SQL LIKE) for the same
// portability reason as the condition's aggregation above — the
// plugintest in-memory DB's WHERE parser only supports "=" and
// IS [NOT] NULL, not LIKE.
if req.IdempotencyKey != "" {
marker := "[automation:" + req.IdempotencyKey + "]"
existing, err := p.db.Query(`SELECT note FROM task_time_logs WHERE task_id = $1`, req.Task.ID)
if err == nil {
for _, row := range existing.Rows {
if strings.Contains(newRowScanner(existing.Columns, row).str("note"), marker) {
return plugin.ActionResult{Applied: false}
}
}
}
}

note := cfg.Note
if req.IdempotencyKey != "" {
if note != "" {
note += " "
}
note += "[automation:" + req.IdempotencyKey + "]"
}

now := nowStr()
if _, err := p.db.Exec(
`INSERT INTO task_time_logs (task_id, member_id, spent_date, minutes_spent, note, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
req.Task.ID, cfg.MemberID, cfg.SpentDate, cfg.MinutesSpent, note, cfg.MemberID, now, now,
); err != nil {
return plugin.ActionResult{Applied: false, Error: "insert time log: " + err.Error()}
}
return plugin.ActionResult{Applied: true}
}
134 changes: 134 additions & 0 deletions backend/automation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"testing"

plugin "github.com/Paca-AI/plugin-sdk-go"
"github.com/Paca-AI/plugin-sdk-go/plugintest"
)

func conditionReqWithConfig(cfg any) plugintest.ConditionRequest {
return plugintest.ConditionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}.WithJSONConfig(cfg)
}

func actionReqWithConfig(cfg any) plugintest.ActionRequest {
return plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}}.WithJSONConfig(cfg)
}

func actionReqWithConfigAndKey(cfg any, key string) plugintest.ActionRequest {
return plugintest.ActionRequest{Task: plugin.TaskSnapshot{ID: testTaskID}, IdempotencyKey: key}.WithJSONConfig(cfg)
}

// ── Condition: total_minutes_exceeds ──────────────────────────────────────────

func TestConditionTotalMinutesExceeds_InvalidConfig(t *testing.T) {
tc := setupPlugin(t)
result := tc.EvaluateCondition(automationConditionTotalMinutesExceeds, conditionReqWithConfig(map[string]any{}))
if result.Matched {
t.Fatal("expected Matched=false for missing threshold_minutes")
}
}

func TestConditionTotalMinutesExceeds_NoLogsDoesNotMatch(t *testing.T) {
tc := setupPlugin(t)
cfg := map[string]any{"threshold_minutes": 60}
result := tc.EvaluateCondition(automationConditionTotalMinutesExceeds, conditionReqWithConfig(cfg))
if result.Matched {
t.Fatal("expected Matched=false when no time has been logged")
}
}

func TestConditionTotalMinutesExceeds_SumsAcrossEntries(t *testing.T) {
tc := setupPlugin(t)
tc.DB.SeedRows("task_time_logs",
[]string{"id", "task_id", "member_id", "spent_date", "minutes_spent", "note", "created_by", "created_at", "updated_at"},
[][]any{
{"log-1", testTaskID, testMemberID, "2026-07-01", 40, "", testMemberID, "t", "t"},
{"log-2", testTaskID, testMember2ID, "2026-07-02", 30, "", testMember2ID, "t", "t"},
})

cfg := map[string]any{"threshold_minutes": 60}
result := tc.EvaluateCondition(automationConditionTotalMinutesExceeds, conditionReqWithConfig(cfg))
if !result.Matched {
t.Fatal("expected Matched=true: 40+30=70 > 60")
}

cfg2 := map[string]any{"threshold_minutes": 100}
result2 := tc.EvaluateCondition(automationConditionTotalMinutesExceeds, conditionReqWithConfig(cfg2))
if result2.Matched {
t.Fatal("expected Matched=false: 40+30=70 is not > 100")
}
}

func TestConditionTotalMinutesExceeds_FiltersByMember(t *testing.T) {
tc := setupPlugin(t)
tc.DB.SeedRows("task_time_logs",
[]string{"id", "task_id", "member_id", "spent_date", "minutes_spent", "note", "created_by", "created_at", "updated_at"},
[][]any{
{"log-1", testTaskID, testMemberID, "2026-07-01", 40, "", testMemberID, "t", "t"},
{"log-2", testTaskID, testMember2ID, "2026-07-02", 100, "", testMember2ID, "t", "t"},
})

cfg := map[string]any{"threshold_minutes": 60, "member_id": testMemberID}
result := tc.EvaluateCondition(automationConditionTotalMinutesExceeds, conditionReqWithConfig(cfg))
if result.Matched {
t.Fatal("expected Matched=false: testMemberID alone only logged 40 minutes")
}
}

// ── Action: log_time ───────────────────────────────────────────────────────────

func TestActionLogTime_MissingMemberID(t *testing.T) {
tc := setupPlugin(t)
result := tc.RunAction(automationActionLogTime, actionReqWithConfig(map[string]any{
"spent_date": "2026-07-30", "minutes_spent": 30,
}))
if result.Applied {
t.Fatal("expected Applied=false for missing member_id")
}
}

func TestActionLogTime_MissingMinutes(t *testing.T) {
tc := setupPlugin(t)
result := tc.RunAction(automationActionLogTime, actionReqWithConfig(map[string]any{
"member_id": testMemberID, "spent_date": "2026-07-30",
}))
if result.Applied {
t.Fatal("expected Applied=false for missing/zero minutes_spent")
}
}

func TestActionLogTime_Succeeds(t *testing.T) {
tc := setupPlugin(t)
cfg := map[string]any{"member_id": testMemberID, "spent_date": "2026-07-30", "minutes_spent": 45, "note": "auto-logged"}
result := tc.RunAction(automationActionLogTime, actionReqWithConfig(cfg))
if !result.Applied {
t.Fatalf("expected Applied=true, got error: %s", result.Error)
}

rows := tc.DB.AllRows("task_time_logs")
if len(rows) != 1 {
t.Fatalf("expected 1 row inserted, got %d", len(rows))
}
}

func TestActionLogTime_IdempotentOnRetry(t *testing.T) {
tc := setupPlugin(t)
cfg := map[string]any{"member_id": testMemberID, "spent_date": "2026-07-30", "minutes_spent": 45}
req := actionReqWithConfigAndKey(cfg, "run-1-node-2")

first := tc.RunAction(automationActionLogTime, req)
if !first.Applied {
t.Fatalf("expected first run Applied=true, got error: %s", first.Error)
}

second := tc.RunAction(automationActionLogTime, req)
if second.Applied {
t.Fatal("expected retried run with the same idempotency key to be a no-op (Applied=false)")
}

rows := tc.DB.AllRows("task_time_logs")
if len(rows) != 1 {
t.Fatalf("expected exactly 1 row after retry, got %d (time was double-logged)", len(rows))
}
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ module github.com/Paca-AI/first-party/time-logging

go 1.24

require github.com/Paca-AI/plugin-sdk-go v0.2.1
require github.com/Paca-AI/plugin-sdk-go v0.3.1
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
github.com/Paca-AI/plugin-sdk-go v0.2.1 h1:jz8plKkll/3Zlfgp5lotO1APmkk9czCUHkLRANxNIDM=
github.com/Paca-AI/plugin-sdk-go v0.2.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw=
github.com/Paca-AI/plugin-sdk-go v0.3.1 h1:iwQbGAk1V/7DWmrPXiefKyZtgB68fjFtgJ2Kb5pxxMI=
github.com/Paca-AI/plugin-sdk-go v0.3.1/go.mod h1:5WeC6cSEf2wM1ovICZbDaVky9oi5id/Qpdfc5LDAQnw=
3 changes: 3 additions & 0 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func (p *timeLoggingPlugin) Init(ctx *plugin.Context) error {
ctx.Route("PATCH", "/time-logs/all/:logId", p.updateTimeLogGlobal)
ctx.Route("DELETE", "/time-logs/all/:logId", p.deleteTimeLogGlobal)

// Automation graph nodes (Condition/Action)
p.registerAutomationNodes(ctx)

return nil
}

Expand Down
7 changes: 7 additions & 0 deletions backend/timelogs.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ func (p *timeLoggingPlugin) createTimeLog(req *plugin.Request, res *plugin.Respo
"spent_date": b.SpentDate,
"_description": fmt.Sprintf("logged %d minutes on %s", b.MinutesSpent, b.SpentDate),
})
plugin.EmitEvent("time_logging.entry_created", map[string]any{
"project_id": projectID,
"task_id": taskID,
"member_id": memberID,
"minutes_spent": b.MinutesSpent,
"spent_date": b.SpentDate,
})
created(res, tl)
}

Expand Down
39 changes: 39 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,45 @@
}
]
},
"automation": {
"triggers": [
{
"type": "time_logging.entry_created",
"label": "Time Logging: Entry Created",
"eventTopic": "time_logging.entry_created"
}
],
"conditions": [
{
"type": "time_logging.total_minutes_exceeds",
"label": "Time Logging: Total Minutes Exceeds",
"configSchema": {
"type": "object",
"required": ["threshold_minutes"],
"properties": {
"threshold_minutes": { "type": "integer", "title": "Threshold (minutes)", "minimum": 1 },
"member_id": { "type": "string", "title": "Member (optional — omit to sum every member)", "format": "member" }
}
}
}
],
"actions": [
{
"type": "time_logging.log_time",
"label": "Time Logging: Log Time",
"configSchema": {
"type": "object",
"required": ["member_id", "spent_date", "minutes_spent"],
"properties": {
"member_id": { "type": "string", "title": "Member", "format": "member" },
"spent_date": { "type": "string", "title": "Date", "format": "date" },
"minutes_spent": { "type": "integer", "title": "Minutes Spent", "minimum": 1 },
"note": { "type": "string", "title": "Note", "format": "textarea" }
}
}
}
]
},
"mcp": {
"remoteEntryUrl": "/plugins-mcp/com.paca.time-logging/mcp.js"
}
Expand Down
Loading