Compare commits
6 Commits
f6a86e3933
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e6d05e055 | |||
|
|
442cc3ef53 | ||
|
|
5e54167009 | ||
|
|
65715f7ad3 | ||
| 537e65ea6d | |||
| e208c62df3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,5 +33,6 @@ bin/
|
||||
OB1/
|
||||
ui/node_modules/
|
||||
ui/.svelte-kit/
|
||||
internal/app/ui/dist/
|
||||
internal/app/ui/dist/*
|
||||
!internal/app/ui/dist/placeholder.txt
|
||||
.codex
|
||||
|
||||
53
Makefile
53
Makefile
@@ -4,7 +4,10 @@ SERVER_BIN := $(BIN_DIR)/amcs-server
|
||||
CMD_SERVER := ./cmd/amcs-server
|
||||
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
||||
UI_DIR := $(CURDIR)/ui
|
||||
AMCS_UI_BACKEND ?= http://127.0.0.1:8080
|
||||
PATCH_INCREMENT ?= 1
|
||||
RELEASE_VERSION ?=
|
||||
RELEASE_REMOTE ?= origin
|
||||
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
||||
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
@@ -20,7 +23,7 @@ LDFLAGS := -s -w \
|
||||
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
||||
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
||||
|
||||
.PHONY: all build clean migrate release-version test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
|
||||
.PHONY: all build clean migrate release-version release-build test generate-migrations generate-models check-schema-drift build-cli ui-install ui-build ui-dev ui-check help
|
||||
|
||||
all: build
|
||||
|
||||
@@ -31,13 +34,14 @@ help:
|
||||
@echo " test Run all tests (includes UI check)"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " migrate Run database migrations"
|
||||
@echo " release-version Tag and push a new patch release (PATCH_INCREMENT=N)"
|
||||
@echo " release-version Tag and push a release (auto patch bump or RELEASE_VERSION=vX.Y.Z)"
|
||||
@echo " release-build Build with a specific release tag (RELEASE_VERSION=vX.Y.Z)"
|
||||
@echo " generate-migrations Generate SQL migration from DBML schema files"
|
||||
@echo " generate-models Generate Go models from DBML schema"
|
||||
@echo " check-schema-drift Verify generated migration matches current schema"
|
||||
@echo " ui-install Install UI dependencies"
|
||||
@echo " ui-build Build UI assets"
|
||||
@echo " ui-dev Start UI dev server"
|
||||
@echo " ui-dev Start UI dev server with local API proxy"
|
||||
@echo " ui-check Run UI type checks"
|
||||
|
||||
build: ui-build
|
||||
@@ -51,7 +55,7 @@ ui-build: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run build
|
||||
|
||||
ui-dev: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run dev
|
||||
cd $(UI_DIR) && VITE_API_URL=/api AMCS_UI_BACKEND=$(AMCS_UI_BACKEND) $(PNPM) run dev
|
||||
|
||||
ui-check: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run check
|
||||
@@ -64,22 +68,41 @@ release-version:
|
||||
@case "$(PATCH_INCREMENT)" in \
|
||||
''|*[!0-9]*|0) echo "PATCH_INCREMENT must be a positive integer" >&2; exit 1 ;; \
|
||||
esac
|
||||
@latest=$$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1); \
|
||||
if [ -z "$$latest" ]; then latest="v0.0.0"; fi; \
|
||||
version=$${latest#v}; \
|
||||
major=$${version%%.*}; \
|
||||
rest=$${version#*.}; \
|
||||
minor=$${rest%%.*}; \
|
||||
patch=$${rest##*.}; \
|
||||
next_patch=$$((patch + $(PATCH_INCREMENT))); \
|
||||
next_tag="v$$major.$$minor.$$next_patch"; \
|
||||
@if ! git diff --quiet || ! git diff --cached --quiet; then \
|
||||
echo "Refusing to release from a dirty working tree. Commit or stash changes first." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@next_tag="$(RELEASE_VERSION)"; \
|
||||
if [ -z "$$next_tag" ]; then \
|
||||
latest=$$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1); \
|
||||
if [ -z "$$latest" ]; then latest="v0.0.0"; fi; \
|
||||
version=$${latest#v}; \
|
||||
major=$${version%%.*}; \
|
||||
rest=$${version#*.}; \
|
||||
minor=$${rest%%.*}; \
|
||||
patch=$${rest##*.}; \
|
||||
next_patch=$$((patch + $(PATCH_INCREMENT))); \
|
||||
next_tag="v$$major.$$minor.$$next_patch"; \
|
||||
fi; \
|
||||
case "$$next_tag" in \
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||
*) echo "RELEASE_VERSION must look like vX.Y.Z (got '$$next_tag')" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
if git rev-parse -q --verify "refs/tags/$$next_tag" >/dev/null; then \
|
||||
echo "$$next_tag already exists" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
git tag -a "$$next_tag" -m "Release $$next_tag"; \
|
||||
git push origin "$$next_tag"; \
|
||||
echo "$$next_tag"
|
||||
git push $(RELEASE_REMOTE) "$$next_tag"; \
|
||||
$(MAKE) release-build RELEASE_VERSION="$$next_tag"; \
|
||||
echo "Released $$next_tag"
|
||||
|
||||
release-build:
|
||||
@case "$(RELEASE_VERSION)" in \
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||
*) echo "RELEASE_VERSION must look like vX.Y.Z" >&2; exit 1 ;; \
|
||||
esac
|
||||
@$(MAKE) build build-cli VERSION_TAG="$(RELEASE_VERSION)"
|
||||
|
||||
migrate:
|
||||
./scripts/migrate.sh
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module git.warky.dev/wdevs/amcs
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/bitechdev/ResolveSpec v1.0.86
|
||||
github.com/bitechdev/ResolveSpec v1.0.87
|
||||
github.com/google/jsonschema-go v0.4.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
|
||||
2
go.sum
2
go.sum
@@ -36,6 +36,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitechdev/ResolveSpec v1.0.86 h1:a4yFMMDizrmvDOV61cj/+kD+mEtKL/5EIHY2GcP3uJU=
|
||||
github.com/bitechdev/ResolveSpec v1.0.86/go.mod h1:YZOY2YCD0Kmb+pjAMhOqPh4q82Hij57F/CLlCMkzT78=
|
||||
github.com/bitechdev/ResolveSpec v1.0.87 h1:zLiHynLK8LLpXIfCZOjL5Iy1COBS6YZcWE1BHKfYqbA=
|
||||
github.com/bitechdev/ResolveSpec v1.0.87/go.mod h1:YZOY2YCD0Kmb+pjAMhOqPh4q82Hij57F/CLlCMkzT78=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
||||
@@ -205,6 +205,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
Projects: tools.NewProjectsTool(db, activeProjects),
|
||||
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
|
||||
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
|
||||
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
|
||||
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
|
||||
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
|
||||
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),
|
||||
@@ -213,10 +214,10 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
Backfill: backfillTool,
|
||||
Reparse: tools.NewReparseMetadataTool(db, bgMetadata, cfg.Capture, activeProjects, logger),
|
||||
RetryMetadata: tools.NewRetryEnrichmentTool(enrichmentRetryer),
|
||||
Maintenance: tools.NewMaintenanceTool(db),
|
||||
Skills: tools.NewSkillsTool(db, activeProjects),
|
||||
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
|
||||
Describe: tools.NewDescribeTool(db, mcpserver.BuildToolCatalog()),
|
||||
//Maintenance: tools.NewMaintenanceTool(db),
|
||||
Skills: tools.NewSkillsTool(db, activeProjects),
|
||||
ChatHistory: tools.NewChatHistoryTool(db, activeProjects),
|
||||
Describe: tools.NewDescribeTool(db, mcpserver.BuildToolCatalog()),
|
||||
}
|
||||
|
||||
mcpHandlers, err := mcpserver.NewHandlers(cfg.MCP, logger, toolSet, activeProjects.Clear)
|
||||
@@ -246,8 +247,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
mux.HandleFunc("/llms.txt", serveLLMSTXT)
|
||||
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
|
||||
mux.HandleFunc("/robots.txt", serveRobotsTXT)
|
||||
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||
mux.HandleFunc("/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||
mux.Handle("/api/status", authMiddleware(statusAPIHandler(info, accessTracker, oauthEnabled)))
|
||||
mux.HandleFunc("/status", publicStatusHandler(accessTracker))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/resolvespec"
|
||||
"github.com/uptrace/bunrouter"
|
||||
@@ -26,6 +27,21 @@ func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware
|
||||
|
||||
rsMount := http.StripPrefix("/api/rs", rsRouter)
|
||||
protectedRSMount := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
|
||||
trimmed := strings.TrimRight(r.URL.Path, "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "/"
|
||||
}
|
||||
clone := r.Clone(r.Context())
|
||||
clone.URL.Path = trimmed
|
||||
if clone.URL.RawPath != "" {
|
||||
clone.URL.RawPath = strings.TrimRight(clone.URL.RawPath, "/")
|
||||
if clone.URL.RawPath == "" {
|
||||
clone.URL.RawPath = "/"
|
||||
}
|
||||
}
|
||||
r = clone
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
rsMount.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -50,15 +66,36 @@ func registerResolveSpecGuards(rs *resolvespec.Handler) {
|
||||
mutableByEntity := map[string]map[string]struct{}{
|
||||
"projects": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"thoughts": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"plans": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"learnings": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"agent_skills": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"agent_guardrails": {
|
||||
"create": {},
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
"stored_files": {
|
||||
"update": {},
|
||||
"delete": {},
|
||||
},
|
||||
}
|
||||
@@ -98,28 +135,35 @@ type resolveSpecModel struct {
|
||||
}
|
||||
|
||||
func resolveSpecModels() []resolveSpecModel {
|
||||
//This must be generated with relspec to include all models
|
||||
//Use the relspec command with template generation. It supprot templ.
|
||||
return []resolveSpecModel{
|
||||
{schema: "public", entity: "activities", model: generatedmodels.ModelPublicActivities{}},
|
||||
// {schema: "public", entity: "activities", model: generatedmodels.ModelPublicActivities{}},
|
||||
{schema: "public", entity: "agent_guardrails", model: generatedmodels.ModelPublicAgentGuardrails{}},
|
||||
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
|
||||
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
|
||||
{schema: "public", entity: "contact_interactions", model: generatedmodels.ModelPublicContactInteractions{}},
|
||||
// {schema: "public", entity: "contact_interactions", model: generatedmodels.ModelPublicContactInteractions{}},
|
||||
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
|
||||
{schema: "public", entity: "family_members", model: generatedmodels.ModelPublicFamilyMembers{}},
|
||||
{schema: "public", entity: "household_items", model: generatedmodels.ModelPublicHouseholdItems{}},
|
||||
{schema: "public", entity: "household_vendors", model: generatedmodels.ModelPublicHouseholdVendors{}},
|
||||
{schema: "public", entity: "important_dates", model: generatedmodels.ModelPublicImportantDates{}},
|
||||
// {schema: "public", entity: "family_members", model: generatedmodels.ModelPublicFamilyMembers{}},
|
||||
// {schema: "public", entity: "household_items", model: generatedmodels.ModelPublicHouseholdItems{}},
|
||||
// {schema: "public", entity: "household_vendors", model: generatedmodels.ModelPublicHouseholdVendors{}},
|
||||
// {schema: "public", entity: "important_dates", model: generatedmodels.ModelPublicImportantDates{}},
|
||||
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
|
||||
{schema: "public", entity: "maintenance_logs", model: generatedmodels.ModelPublicMaintenanceLogs{}},
|
||||
{schema: "public", entity: "maintenance_tasks", model: generatedmodels.ModelPublicMaintenanceTasks{}},
|
||||
{schema: "public", entity: "meal_plans", model: generatedmodels.ModelPublicMealPlans{}},
|
||||
{schema: "public", entity: "opportunities", model: generatedmodels.ModelPublicOpportunities{}},
|
||||
{schema: "public", entity: "professional_contacts", model: generatedmodels.ModelPublicProfessionalContacts{}},
|
||||
// {schema: "public", entity: "maintenance_logs", model: generatedmodels.ModelPublicMaintenanceLogs{}},
|
||||
// {schema: "public", entity: "maintenance_tasks", model: generatedmodels.ModelPublicMaintenanceTasks{}},
|
||||
// {schema: "public", entity: "meal_plans", model: generatedmodels.ModelPublicMealPlans{}},
|
||||
// {schema: "public", entity: "opportunities", model: generatedmodels.ModelPublicOpportunities{}},
|
||||
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
|
||||
{schema: "public", entity: "plan_guardrails", model: generatedmodels.ModelPublicPlanGuardrails{}},
|
||||
{schema: "public", entity: "plan_related_plans", model: generatedmodels.ModelPublicPlanRelatedPlans{}},
|
||||
{schema: "public", entity: "plan_skills", model: generatedmodels.ModelPublicPlanSkills{}},
|
||||
{schema: "public", entity: "plans", model: generatedmodels.ModelPublicPlans{}},
|
||||
// {schema: "public", entity: "professional_contacts", model: generatedmodels.ModelPublicProfessionalContacts{}},
|
||||
{schema: "public", entity: "project_guardrails", model: generatedmodels.ModelPublicProjectGuardrails{}},
|
||||
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
||||
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
||||
{schema: "public", entity: "recipes", model: generatedmodels.ModelPublicRecipes{}},
|
||||
{schema: "public", entity: "shopping_lists", model: generatedmodels.ModelPublicShoppingLists{}},
|
||||
// {schema: "public", entity: "recipes", model: generatedmodels.ModelPublicRecipes{}},
|
||||
// {schema: "public", entity: "shopping_lists", model: generatedmodels.ModelPublicShoppingLists{}},
|
||||
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
||||
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
||||
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
||||
|
||||
@@ -63,10 +63,25 @@ func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
|
||||
}{
|
||||
{name: "learnings read", entity: "learnings", operation: "read"},
|
||||
{name: "projects create", entity: "projects", operation: "create"},
|
||||
{name: "projects update", entity: "projects", operation: "update"},
|
||||
{name: "projects delete", entity: "projects", operation: "delete"},
|
||||
{name: "plans create", entity: "plans", operation: "create"},
|
||||
{name: "plans update", entity: "plans", operation: "update"},
|
||||
{name: "plans delete", entity: "plans", operation: "delete"},
|
||||
{name: "learnings create", entity: "learnings", operation: "create"},
|
||||
{name: "learnings update", entity: "learnings", operation: "update"},
|
||||
{name: "learnings delete", entity: "learnings", operation: "delete"},
|
||||
{name: "thoughts create", entity: "thoughts", operation: "create"},
|
||||
{name: "thoughts update", entity: "thoughts", operation: "update"},
|
||||
{name: "thoughts delete", entity: "thoughts", operation: "delete"},
|
||||
{name: "agent_skills create", entity: "agent_skills", operation: "create"},
|
||||
{name: "agent_skills update", entity: "agent_skills", operation: "update"},
|
||||
{name: "agent_skills delete", entity: "agent_skills", operation: "delete"},
|
||||
{name: "agent_guardrails create", entity: "agent_guardrails", operation: "create"},
|
||||
{name: "agent_guardrails update", entity: "agent_guardrails", operation: "update"},
|
||||
{name: "agent_guardrails delete", entity: "agent_guardrails", operation: "delete"},
|
||||
{name: "stored_files update", entity: "stored_files", operation: "update"},
|
||||
{name: "stored_files delete", entity: "stored_files", operation: "delete"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -100,32 +115,18 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||
wantMessageIn string
|
||||
}{
|
||||
{
|
||||
name: "create not allowed on thoughts",
|
||||
entity: "thoughts",
|
||||
name: "mutations blocked for non-allowlisted operation",
|
||||
entity: "stored_files",
|
||||
operation: "create",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "create" is not allowed for public.thoughts`,
|
||||
},
|
||||
{
|
||||
name: "delete not allowed on projects",
|
||||
entity: "projects",
|
||||
operation: "delete",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.projects`,
|
||||
wantMessageIn: `operation "create" is not allowed for public.stored_files`,
|
||||
},
|
||||
{
|
||||
name: "mutations blocked for non-allowlisted entity",
|
||||
entity: "stored_files",
|
||||
entity: "maintenance_logs",
|
||||
operation: "delete",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.stored_files`,
|
||||
},
|
||||
{
|
||||
name: "mutations blocked for learnings",
|
||||
entity: "learnings",
|
||||
operation: "delete",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.learnings`,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.maintenance_logs`,
|
||||
},
|
||||
{
|
||||
name: "unknown operation is rejected",
|
||||
|
||||
@@ -29,8 +29,24 @@ type statusAPIResponse struct {
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
}
|
||||
|
||||
type publicClientStatus struct {
|
||||
KeyID string `json:"key_id"`
|
||||
RequestCount int `json:"request_count"`
|
||||
LastAccessedAt time.Time `json:"last_accessed_at"`
|
||||
}
|
||||
|
||||
type publicStatusResponse struct {
|
||||
ConnectedCount int `json:"connected_count"`
|
||||
ConnectedWindow string `json:"connected_window"`
|
||||
Entries []publicClientStatus `json:"entries"`
|
||||
}
|
||||
|
||||
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
||||
entries := tracker.Snapshot()
|
||||
metrics := tracker.Metrics(20)
|
||||
metrics.TopIPs = nil
|
||||
metrics.TopAgents = nil
|
||||
metrics.TopTools = nil
|
||||
return statusAPIResponse{
|
||||
Title: "Avelon Memory Crystal Server (AMCS)",
|
||||
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
|
||||
@@ -40,8 +56,8 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
|
||||
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||
TotalKnown: len(entries),
|
||||
ConnectedWindow: "last 10 minutes",
|
||||
Entries: entries,
|
||||
Metrics: tracker.Metrics(20),
|
||||
Entries: nil,
|
||||
Metrics: metrics,
|
||||
OAuthEnabled: oauthEnabled,
|
||||
}
|
||||
}
|
||||
@@ -55,7 +71,7 @@ func fallback(value, defaultValue string) string {
|
||||
|
||||
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/status" && r.URL.Path != "/status" {
|
||||
if r.URL.Path != "/api/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -75,6 +91,47 @@ func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEna
|
||||
}
|
||||
}
|
||||
|
||||
func publicStatusHandler(tracker *auth.AccessTracker) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.UTC().Add(-connectedWindow)
|
||||
snapshot := tracker.Snapshot()
|
||||
entries := make([]publicClientStatus, 0, len(snapshot))
|
||||
for _, item := range snapshot {
|
||||
if item.LastAccessedAt.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, publicClientStatus{
|
||||
KeyID: item.KeyID,
|
||||
RequestCount: item.RequestCount,
|
||||
LastAccessedAt: item.LastAccessedAt,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(publicStatusResponse{
|
||||
ConnectedCount: len(entries),
|
||||
ConnectedWindow: "last 10 minutes",
|
||||
Entries: entries,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
|
||||
@@ -43,11 +43,8 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||
if snapshot.ConnectedCount != 1 {
|
||||
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
||||
}
|
||||
if len(snapshot.Entries) != 1 {
|
||||
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
|
||||
}
|
||||
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||
if len(snapshot.Entries) != 0 {
|
||||
t.Fatalf("len(Entries) = %d, want 0 for counts-only status", len(snapshot.Entries))
|
||||
}
|
||||
if snapshot.Metrics.TotalRequests != 1 {
|
||||
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
||||
@@ -61,6 +58,9 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||
if snapshot.Metrics.UniqueTools != 1 {
|
||||
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools)
|
||||
}
|
||||
if len(snapshot.Metrics.TopIPs) != 0 || len(snapshot.Metrics.TopAgents) != 0 || len(snapshot.Metrics.TopTools) != 0 {
|
||||
t.Fatalf("Top breakdowns should be hidden in counts-only status: %+v", snapshot.Metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||
@@ -86,23 +86,49 @@ func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAPIHandlerSupportsStatusPath(t *testing.T) {
|
||||
func TestStatusAPIHandlerRejectsStatusPath(t *testing.T) {
|
||||
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicStatusHandlerReturnsConnectedClientsOnly(t *testing.T) {
|
||||
tracker := auth.NewAccessTracker()
|
||||
now := time.Now().UTC()
|
||||
tracker.Record("recent-client", "/mcp", "127.0.0.1:1234", "tester", "list_projects", now.Add(-2*time.Minute))
|
||||
tracker.Record("stale-client", "/mcp", "127.0.0.1:9999", "tester", "list_projects", now.Add(-30*time.Minute))
|
||||
|
||||
handler := publicStatusHandler(tracker)
|
||||
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var payload statusAPIResponse
|
||||
var payload publicStatusResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Version != "v1" {
|
||||
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
||||
if payload.ConnectedCount != 1 {
|
||||
t.Fatalf("ConnectedCount = %d, want 1", payload.ConnectedCount)
|
||||
}
|
||||
if len(payload.Entries) != 1 {
|
||||
t.Fatalf("len(Entries) = %d, want 1", len(payload.Entries))
|
||||
}
|
||||
if payload.Entries[0].KeyID != "recent-client" {
|
||||
t.Fatalf("Entries[0].KeyID = %q, want %q", payload.Entries[0].KeyID, "recent-client")
|
||||
}
|
||||
if payload.Entries[0].LastAccessedAt.Before(now.Add(-11 * time.Minute)) {
|
||||
t.Fatalf("Entries[0].LastAccessedAt = %v, expected recent timestamp", payload.Entries[0].LastAccessedAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicActivities struct {
|
||||
bun.BaseModel `bun:"table:public.activities,alias:activities"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
ActivityType resolvespec_common.SqlString `bun:"activity_type,type:text,nullzero," json:"activity_type"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,nullzero," json:"day_of_week"`
|
||||
EndDate resolvespec_common.SqlDate `bun:"end_date,type:date,nullzero," json:"end_date"`
|
||||
EndTime resolvespec_common.SqlTime `bun:"end_time,type:time,nullzero," json:"end_time"`
|
||||
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
|
||||
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
StartDate resolvespec_common.SqlDate `bun:"start_date,type:date,nullzero," json:"start_date"`
|
||||
StartTime resolvespec_common.SqlTime `bun:"start_time,type:time,nullzero," json:"start_time"`
|
||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicActivities
|
||||
func (m ModelPublicActivities) TableName() string {
|
||||
return "public.activities"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicActivities
|
||||
func (m ModelPublicActivities) TableNameOnly() string {
|
||||
return "activities"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicActivities
|
||||
func (m ModelPublicActivities) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicActivities) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicActivities) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicActivities) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicActivities) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicActivities) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicActivities) GetPrefix() string {
|
||||
return "ACT"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type ModelPublicAgentGuardrails struct {
|
||||
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
||||
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
|
||||
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type ModelPublicAgentSkills struct {
|
||||
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
||||
RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicContactInteractions struct {
|
||||
bun.BaseModel `bun:"table:public.contact_interactions,alias:contact_interactions"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,notnull," json:"contact_id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
FollowUpNeeded bool `bun:"follow_up_needed,type:boolean,default:false,notnull," json:"follow_up_needed"`
|
||||
FollowUpNotes resolvespec_common.SqlString `bun:"follow_up_notes,type:text,nullzero," json:"follow_up_notes"`
|
||||
InteractionType resolvespec_common.SqlString `bun:"interaction_type,type:text,notnull," json:"interaction_type"`
|
||||
OccurredAt resolvespec_common.SqlTimeStamp `bun:"occurred_at,type:timestamptz,default:now(),notnull," json:"occurred_at"`
|
||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicContactInteractions
|
||||
func (m ModelPublicContactInteractions) TableName() string {
|
||||
return "public.contact_interactions"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicContactInteractions
|
||||
func (m ModelPublicContactInteractions) TableNameOnly() string {
|
||||
return "contact_interactions"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicContactInteractions
|
||||
func (m ModelPublicContactInteractions) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicContactInteractions) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicContactInteractions) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicContactInteractions) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicContactInteractions) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicContactInteractions) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicContactInteractions) GetPrefix() string {
|
||||
return "CIO"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicFamilyMembers struct {
|
||||
bun.BaseModel `bun:"table:public.family_members,alias:family_members"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
BirthDate resolvespec_common.SqlDate `bun:"birth_date,type:date,nullzero," json:"birth_date"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
Relationship resolvespec_common.SqlString `bun:"relationship,type:text,nullzero," json:"relationship"`
|
||||
RelFamilyMemberIDPublicActivities []*ModelPublicActivities `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicactivities,omitempty"` // Has many ModelPublicActivities
|
||||
RelFamilyMemberIDPublicImportantDates []*ModelPublicImportantDates `bun:"rel:has-many,join:id=family_member_id" json:"relfamilymemberidpublicimportantdates,omitempty"` // Has many ModelPublicImportantDates
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicFamilyMembers
|
||||
func (m ModelPublicFamilyMembers) TableName() string {
|
||||
return "public.family_members"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicFamilyMembers
|
||||
func (m ModelPublicFamilyMembers) TableNameOnly() string {
|
||||
return "family_members"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicFamilyMembers
|
||||
func (m ModelPublicFamilyMembers) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicFamilyMembers) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicFamilyMembers) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicFamilyMembers) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicFamilyMembers) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicFamilyMembers) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicFamilyMembers) GetPrefix() string {
|
||||
return "FMA"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicHouseholdItems struct {
|
||||
bun.BaseModel `bun:"table:public.household_items,alias:household_items"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Details resolvespec_common.SqlJSONB `bun:"details,type:jsonb,default:'{}',notnull," json:"details"`
|
||||
Location resolvespec_common.SqlString `bun:"location,type:text,nullzero," json:"location"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicHouseholdItems
|
||||
func (m ModelPublicHouseholdItems) TableName() string {
|
||||
return "public.household_items"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicHouseholdItems
|
||||
func (m ModelPublicHouseholdItems) TableNameOnly() string {
|
||||
return "household_items"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicHouseholdItems
|
||||
func (m ModelPublicHouseholdItems) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicHouseholdItems) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicHouseholdItems) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicHouseholdItems) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicHouseholdItems) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicHouseholdItems) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicHouseholdItems) GetPrefix() string {
|
||||
return "HIO"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicHouseholdVendors struct {
|
||||
bun.BaseModel `bun:"table:public.household_vendors,alias:household_vendors"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||
LastUsed resolvespec_common.SqlDate `bun:"last_used,type:date,nullzero," json:"last_used"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
|
||||
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
|
||||
ServiceType resolvespec_common.SqlString `bun:"service_type,type:text,nullzero," json:"service_type"`
|
||||
Website resolvespec_common.SqlString `bun:"website,type:text,nullzero," json:"website"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicHouseholdVendors
|
||||
func (m ModelPublicHouseholdVendors) TableName() string {
|
||||
return "public.household_vendors"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicHouseholdVendors
|
||||
func (m ModelPublicHouseholdVendors) TableNameOnly() string {
|
||||
return "household_vendors"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicHouseholdVendors
|
||||
func (m ModelPublicHouseholdVendors) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicHouseholdVendors) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicHouseholdVendors) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicHouseholdVendors) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicHouseholdVendors) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicHouseholdVendors) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicHouseholdVendors) GetPrefix() string {
|
||||
return "HVO"
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicImportantDates struct {
|
||||
bun.BaseModel `bun:"table:public.important_dates,alias:important_dates"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
DateValue resolvespec_common.SqlDate `bun:"date_value,type:date,notnull," json:"date_value"`
|
||||
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
RecurringYearly bool `bun:"recurring_yearly,type:boolean,default:false,notnull," json:"recurring_yearly"`
|
||||
ReminderDaysBefore resolvespec_common.SqlInt32 `bun:"reminder_days_before,type:int,default:7,notnull," json:"reminder_days_before"`
|
||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicImportantDates
|
||||
func (m ModelPublicImportantDates) TableName() string {
|
||||
return "public.important_dates"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicImportantDates
|
||||
func (m ModelPublicImportantDates) TableNameOnly() string {
|
||||
return "important_dates"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicImportantDates
|
||||
func (m ModelPublicImportantDates) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicImportantDates) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicImportantDates) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicImportantDates) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicImportantDates) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicImportantDates) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicImportantDates) GetPrefix() string {
|
||||
return "IDM"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicMaintenanceLogs struct {
|
||||
bun.BaseModel `bun:"table:public.maintenance_logs,alias:maintenance_logs"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,default:now(),notnull," json:"completed_at"`
|
||||
Cost resolvespec_common.SqlFloat64 `bun:"cost,type:decimal(10,2),nullzero," json:"cost"`
|
||||
NextAction resolvespec_common.SqlString `bun:"next_action,type:text,nullzero," json:"next_action"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
PerformedBy resolvespec_common.SqlString `bun:"performed_by,type:text,nullzero," json:"performed_by"`
|
||||
TaskID resolvespec_common.SqlUUID `bun:"task_id,type:uuid,notnull," json:"task_id"`
|
||||
RelTaskID *ModelPublicMaintenanceTasks `bun:"rel:has-one,join:task_id=id" json:"reltaskid,omitempty"` // Has one ModelPublicMaintenanceTasks
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicMaintenanceLogs
|
||||
func (m ModelPublicMaintenanceLogs) TableName() string {
|
||||
return "public.maintenance_logs"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceLogs
|
||||
func (m ModelPublicMaintenanceLogs) TableNameOnly() string {
|
||||
return "maintenance_logs"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicMaintenanceLogs
|
||||
func (m ModelPublicMaintenanceLogs) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicMaintenanceLogs) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicMaintenanceLogs) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicMaintenanceLogs) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicMaintenanceLogs) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicMaintenanceLogs) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicMaintenanceLogs) GetPrefix() string {
|
||||
return "MLA"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicMaintenanceTasks struct {
|
||||
bun.BaseModel `bun:"table:public.maintenance_tasks,alias:maintenance_tasks"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
Category resolvespec_common.SqlString `bun:"category,type:text,nullzero," json:"category"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
FrequencyDays resolvespec_common.SqlInt32 `bun:"frequency_days,type:int,nullzero," json:"frequency_days"`
|
||||
LastCompleted resolvespec_common.SqlTimeStamp `bun:"last_completed,type:timestamptz,nullzero," json:"last_completed"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
NextDue resolvespec_common.SqlTimeStamp `bun:"next_due,type:timestamptz,nullzero," json:"next_due"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTaskIDPublicMaintenanceLogs []*ModelPublicMaintenanceLogs `bun:"rel:has-many,join:id=task_id" json:"reltaskidpublicmaintenancelogs,omitempty"` // Has many ModelPublicMaintenanceLogs
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicMaintenanceTasks
|
||||
func (m ModelPublicMaintenanceTasks) TableName() string {
|
||||
return "public.maintenance_tasks"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicMaintenanceTasks
|
||||
func (m ModelPublicMaintenanceTasks) TableNameOnly() string {
|
||||
return "maintenance_tasks"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicMaintenanceTasks
|
||||
func (m ModelPublicMaintenanceTasks) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicMaintenanceTasks) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicMaintenanceTasks) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicMaintenanceTasks) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicMaintenanceTasks) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicMaintenanceTasks) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicMaintenanceTasks) GetPrefix() string {
|
||||
return "MTA"
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicMealPlans struct {
|
||||
bun.BaseModel `bun:"table:public.meal_plans,alias:meal_plans"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
CustomMeal resolvespec_common.SqlString `bun:"custom_meal,type:text,nullzero," json:"custom_meal"`
|
||||
DayOfWeek resolvespec_common.SqlString `bun:"day_of_week,type:text,notnull," json:"day_of_week"`
|
||||
MealType resolvespec_common.SqlString `bun:"meal_type,type:text,notnull," json:"meal_type"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
RecipeID resolvespec_common.SqlUUID `bun:"recipe_id,type:uuid,nullzero," json:"recipe_id"`
|
||||
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
|
||||
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
|
||||
RelRecipeID *ModelPublicRecipes `bun:"rel:has-one,join:recipe_id=id" json:"relrecipeid,omitempty"` // Has one ModelPublicRecipes
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicMealPlans
|
||||
func (m ModelPublicMealPlans) TableName() string {
|
||||
return "public.meal_plans"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicMealPlans
|
||||
func (m ModelPublicMealPlans) TableNameOnly() string {
|
||||
return "meal_plans"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicMealPlans
|
||||
func (m ModelPublicMealPlans) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicMealPlans) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicMealPlans) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicMealPlans) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicMealPlans) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicMealPlans) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicMealPlans) GetPrefix() string {
|
||||
return "MPE"
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicOpportunities struct {
|
||||
bun.BaseModel `bun:"table:public.opportunities,alias:opportunities"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
ContactID resolvespec_common.SqlUUID `bun:"contact_id,type:uuid,nullzero," json:"contact_id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||
ExpectedCloseDate resolvespec_common.SqlDate `bun:"expected_close_date,type:date,nullzero," json:"expected_close_date"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
Stage resolvespec_common.SqlString `bun:"stage,type:text,default:'identified',notnull," json:"stage"`
|
||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
Value resolvespec_common.SqlFloat64 `bun:"value,type:decimal(12,2),nullzero," json:"value"`
|
||||
RelContactID *ModelPublicProfessionalContacts `bun:"rel:has-one,join:contact_id=id" json:"relcontactid,omitempty"` // Has one ModelPublicProfessionalContacts
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicOpportunities
|
||||
func (m ModelPublicOpportunities) TableName() string {
|
||||
return "public.opportunities"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicOpportunities
|
||||
func (m ModelPublicOpportunities) TableNameOnly() string {
|
||||
return "opportunities"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicOpportunities
|
||||
func (m ModelPublicOpportunities) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicOpportunities) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicOpportunities) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicOpportunities) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicOpportunities) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicOpportunities) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicOpportunities) GetPrefix() string {
|
||||
return "OPP"
|
||||
}
|
||||
63
internal/generatedmodels/sql_public_plan_dependencies.go
Normal file
63
internal/generatedmodels/sql_public_plan_dependencies.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicPlanDependencies struct {
|
||||
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
|
||||
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
DependsOnPlanID resolvespec_common.SqlUUID `bun:"depends_on_plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
|
||||
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
|
||||
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
|
||||
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicPlanDependencies
|
||||
func (m ModelPublicPlanDependencies) TableName() string {
|
||||
return "public.plan_dependencies"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicPlanDependencies
|
||||
func (m ModelPublicPlanDependencies) TableNameOnly() string {
|
||||
return "plan_dependencies"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicPlanDependencies
|
||||
func (m ModelPublicPlanDependencies) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicPlanDependencies) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicPlanDependencies) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicPlanDependencies) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicPlanDependencies) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicPlanDependencies) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicPlanDependencies) GetPrefix() string {
|
||||
return "PDL"
|
||||
}
|
||||
63
internal/generatedmodels/sql_public_plan_guardrails.go
Normal file
63
internal/generatedmodels/sql_public_plan_guardrails.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicPlanGuardrails struct {
|
||||
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
|
||||
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
GuardrailID resolvespec_common.SqlUUID `bun:"guardrail_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
|
||||
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
|
||||
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicPlanGuardrails
|
||||
func (m ModelPublicPlanGuardrails) TableName() string {
|
||||
return "public.plan_guardrails"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicPlanGuardrails
|
||||
func (m ModelPublicPlanGuardrails) TableNameOnly() string {
|
||||
return "plan_guardrails"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicPlanGuardrails
|
||||
func (m ModelPublicPlanGuardrails) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicPlanGuardrails) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicPlanGuardrails) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicPlanGuardrails) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicPlanGuardrails) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicPlanGuardrails) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicPlanGuardrails) GetPrefix() string {
|
||||
return "PGL"
|
||||
}
|
||||
63
internal/generatedmodels/sql_public_plan_related_plans.go
Normal file
63
internal/generatedmodels/sql_public_plan_related_plans.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicPlanRelatedPlans struct {
|
||||
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
|
||||
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
PlanAID resolvespec_common.SqlUUID `bun:"plan_a_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
|
||||
PlanBID resolvespec_common.SqlUUID `bun:"plan_b_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
|
||||
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
|
||||
RelPlanBID *ModelPublicPlans `bun:"rel:has-one,join:plan_b_id=id" json:"relplanbid,omitempty"` // Has one ModelPublicPlans
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicPlanRelatedPlans
|
||||
func (m ModelPublicPlanRelatedPlans) TableName() string {
|
||||
return "public.plan_related_plans"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicPlanRelatedPlans
|
||||
func (m ModelPublicPlanRelatedPlans) TableNameOnly() string {
|
||||
return "plan_related_plans"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicPlanRelatedPlans
|
||||
func (m ModelPublicPlanRelatedPlans) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicPlanRelatedPlans) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicPlanRelatedPlans) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicPlanRelatedPlans) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicPlanRelatedPlans) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicPlanRelatedPlans) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicPlanRelatedPlans) GetPrefix() string {
|
||||
return "PRP"
|
||||
}
|
||||
63
internal/generatedmodels/sql_public_plan_skills.go
Normal file
63
internal/generatedmodels/sql_public_plan_skills.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicPlanSkills struct {
|
||||
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
|
||||
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
|
||||
SkillID resolvespec_common.SqlUUID `bun:"skill_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
|
||||
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
||||
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicPlanSkills
|
||||
func (m ModelPublicPlanSkills) TableName() string {
|
||||
return "public.plan_skills"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicPlanSkills
|
||||
func (m ModelPublicPlanSkills) TableNameOnly() string {
|
||||
return "plan_skills"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicPlanSkills
|
||||
func (m ModelPublicPlanSkills) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicPlanSkills) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicPlanSkills) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicPlanSkills) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicPlanSkills) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicPlanSkills) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicPlanSkills) GetPrefix() string {
|
||||
return "PSL"
|
||||
}
|
||||
80
internal/generatedmodels/sql_public_plans.go
Normal file
80
internal/generatedmodels/sql_public_plans.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicPlans struct {
|
||||
bun.BaseModel `bun:"table:public.plans,alias:plans"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
|
||||
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
|
||||
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
|
||||
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
|
||||
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
|
||||
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
||||
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
|
||||
SupersedesPlanID resolvespec_common.SqlUUID `bun:"supersedes_plan_id,type:uuid,nullzero," json:"supersedes_plan_id"`
|
||||
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
|
||||
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
|
||||
RelPlanBIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_b_id" json:"relplanbidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
|
||||
RelPlanIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
||||
RelPlanIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicPlans
|
||||
func (m ModelPublicPlans) TableName() string {
|
||||
return "public.plans"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicPlans
|
||||
func (m ModelPublicPlans) TableNameOnly() string {
|
||||
return "plans"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicPlans
|
||||
func (m ModelPublicPlans) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicPlans) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicPlans) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicPlans) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicPlans) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicPlans) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicPlans) GetPrefix() string {
|
||||
return "PLA"
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicProfessionalContacts struct {
|
||||
bun.BaseModel `bun:"table:public.professional_contacts,alias:professional_contacts"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
Company resolvespec_common.SqlString `bun:"company,type:text,nullzero," json:"company"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Email resolvespec_common.SqlString `bun:"email,type:text,nullzero," json:"email"`
|
||||
FollowUpDate resolvespec_common.SqlDate `bun:"follow_up_date,type:date,nullzero," json:"follow_up_date"`
|
||||
HowWeMet resolvespec_common.SqlString `bun:"how_we_met,type:text,nullzero," json:"how_we_met"`
|
||||
LastContacted resolvespec_common.SqlTimeStamp `bun:"last_contacted,type:timestamptz,nullzero," json:"last_contacted"`
|
||||
LinkedinURL resolvespec_common.SqlString `bun:"linkedin_url,type:text,nullzero," json:"linkedin_url"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
|
||||
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelContactIDPublicContactInteractions []*ModelPublicContactInteractions `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpubliccontactinteractions,omitempty"` // Has many ModelPublicContactInteractions
|
||||
RelContactIDPublicOpportunities []*ModelPublicOpportunities `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpublicopportunities,omitempty"` // Has many ModelPublicOpportunities
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicProfessionalContacts
|
||||
func (m ModelPublicProfessionalContacts) TableName() string {
|
||||
return "public.professional_contacts"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicProfessionalContacts
|
||||
func (m ModelPublicProfessionalContacts) TableNameOnly() string {
|
||||
return "professional_contacts"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicProfessionalContacts
|
||||
func (m ModelPublicProfessionalContacts) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicProfessionalContacts) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicProfessionalContacts) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicProfessionalContacts) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicProfessionalContacts) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicProfessionalContacts) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicProfessionalContacts) GetPrefix() string {
|
||||
return "PCR"
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type ModelPublicProjects struct {
|
||||
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||
RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
|
||||
RelProjectIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||
RelProjectIDPublicPlans []*ModelPublicPlans `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicplans,omitempty"` // Has many ModelPublicPlans
|
||||
RelProjectIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
|
||||
RelProjectIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicRecipes struct {
|
||||
bun.BaseModel `bun:"table:public.recipes,alias:recipes"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CookTimeMinutes resolvespec_common.SqlInt32 `bun:"cook_time_minutes,type:int,nullzero," json:"cook_time_minutes"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Cuisine resolvespec_common.SqlString `bun:"cuisine,type:text,nullzero," json:"cuisine"`
|
||||
Ingredients resolvespec_common.SqlJSONB `bun:"ingredients,type:jsonb,default:'[',notnull," json:"ingredients"`
|
||||
Instructions resolvespec_common.SqlJSONB `bun:"instructions,type:jsonb,default:'[',notnull," json:"instructions"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
PrepTimeMinutes resolvespec_common.SqlInt32 `bun:"prep_time_minutes,type:int,nullzero," json:"prep_time_minutes"`
|
||||
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
|
||||
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
|
||||
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelRecipeIDPublicMealPlans []*ModelPublicMealPlans `bun:"rel:has-many,join:id=recipe_id" json:"relrecipeidpublicmealplans,omitempty"` // Has many ModelPublicMealPlans
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicRecipes
|
||||
func (m ModelPublicRecipes) TableName() string {
|
||||
return "public.recipes"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicRecipes
|
||||
func (m ModelPublicRecipes) TableNameOnly() string {
|
||||
return "recipes"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicRecipes
|
||||
func (m ModelPublicRecipes) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicRecipes) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicRecipes) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicRecipes) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicRecipes) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicRecipes) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicRecipes) GetPrefix() string {
|
||||
return "REC"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicShoppingLists struct {
|
||||
bun.BaseModel `bun:"table:public.shopping_lists,alias:shopping_lists"`
|
||||
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Items resolvespec_common.SqlJSONB `bun:"items,type:jsonb,default:'[',notnull," json:"items"`
|
||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicShoppingLists
|
||||
func (m ModelPublicShoppingLists) TableName() string {
|
||||
return "public.shopping_lists"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicShoppingLists
|
||||
func (m ModelPublicShoppingLists) TableNameOnly() string {
|
||||
return "shopping_lists"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicShoppingLists
|
||||
func (m ModelPublicShoppingLists) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicShoppingLists) GetID() int64 {
|
||||
return m.ID.Int64()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicShoppingLists) GetIDStr() string {
|
||||
return fmt.Sprintf("%v", m.ID)
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicShoppingLists) SetID(newid int64) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicShoppingLists) UpdateID(newid int64) {
|
||||
m.ID.FromString(fmt.Sprintf("%d", newid))
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicShoppingLists) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicShoppingLists) GetPrefix() string {
|
||||
return "SLH"
|
||||
}
|
||||
@@ -36,11 +36,12 @@ type ToolSet struct {
|
||||
Backfill *tools.BackfillTool
|
||||
Reparse *tools.ReparseMetadataTool
|
||||
RetryMetadata *tools.RetryEnrichmentTool
|
||||
Maintenance *tools.MaintenanceTool
|
||||
Skills *tools.SkillsTool
|
||||
ChatHistory *tools.ChatHistoryTool
|
||||
Describe *tools.DescribeTool
|
||||
Learnings *tools.LearningsTool
|
||||
//Maintenance *tools.MaintenanceTool
|
||||
Skills *tools.SkillsTool
|
||||
ChatHistory *tools.ChatHistoryTool
|
||||
Describe *tools.DescribeTool
|
||||
Learnings *tools.LearningsTool
|
||||
Plans *tools.PlansTool
|
||||
}
|
||||
|
||||
// Handlers groups the HTTP handlers produced for an MCP server instance.
|
||||
@@ -85,6 +86,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
|
||||
registerThoughtTools,
|
||||
registerProjectTools,
|
||||
registerLearningTools,
|
||||
registerPlanTools,
|
||||
registerFileTools,
|
||||
registerMaintenanceTools,
|
||||
registerSkillTools,
|
||||
@@ -273,6 +275,100 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "create_plan",
|
||||
Description: "Create a structured plan linked to a project.",
|
||||
}, toolSet.Plans.Create); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_plan",
|
||||
Description: "Retrieve a plan with its dependencies, related plans, skills, and guardrails.",
|
||||
}, toolSet.Plans.Get); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "update_plan",
|
||||
Description: "Update plan fields; only provided fields are changed.",
|
||||
}, toolSet.Plans.Update); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "delete_plan",
|
||||
Description: "Hard-delete a plan by id.",
|
||||
}, toolSet.Plans.Delete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "list_plans",
|
||||
Description: "List plans with optional project, status, priority, owner, tag, and text filters.",
|
||||
}, toolSet.Plans.List); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_plan_dependency",
|
||||
Description: "Mark plan_id as depending on depends_on_plan_id (must complete first).",
|
||||
}, toolSet.Plans.AddDependency); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "remove_plan_dependency",
|
||||
Description: "Remove a dependency between two plans.",
|
||||
}, toolSet.Plans.RemoveDependency); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_related_plan",
|
||||
Description: "Link two plans as thematically related (bidirectional).",
|
||||
}, toolSet.Plans.AddRelated); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "remove_related_plan",
|
||||
Description: "Unlink two related plans.",
|
||||
}, toolSet.Plans.RemoveRelated); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_plan_skill",
|
||||
Description: "Link an agent skill to a plan.",
|
||||
}, toolSet.Plans.AddSkill); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "remove_plan_skill",
|
||||
Description: "Unlink an agent skill from a plan.",
|
||||
}, toolSet.Plans.RemoveSkill); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "list_plan_skills",
|
||||
Description: "List skills linked to a plan.",
|
||||
}, toolSet.Plans.ListSkills); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_plan_guardrail",
|
||||
Description: "Link an agent guardrail to a plan.",
|
||||
}, toolSet.Plans.AddGuardrail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "remove_plan_guardrail",
|
||||
Description: "Unlink an agent guardrail from a plan.",
|
||||
}, toolSet.Plans.RemoveGuardrail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "list_plan_guardrails",
|
||||
Description: "List guardrails linked to a plan.",
|
||||
}, toolSet.Plans.ListGuardrails); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||
server.AddResourceTemplate(&mcp.ResourceTemplate{
|
||||
Name: "stored_file",
|
||||
@@ -326,30 +422,30 @@ func registerMaintenanceTools(server *mcp.Server, logger *slog.Logger, toolSet T
|
||||
}, toolSet.RetryMetadata.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_maintenance_task",
|
||||
Description: "Create a recurring or one-time home maintenance task.",
|
||||
}, toolSet.Maintenance.AddTask); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "log_maintenance",
|
||||
Description: "Log completed maintenance; updates next due date.",
|
||||
}, toolSet.Maintenance.LogWork); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_upcoming_maintenance",
|
||||
Description: "List maintenance tasks due within the next N days.",
|
||||
}, toolSet.Maintenance.GetUpcoming); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "search_maintenance_history",
|
||||
Description: "Search the maintenance log by task name, category, or date range.",
|
||||
}, toolSet.Maintenance.SearchHistory); err != nil {
|
||||
return err
|
||||
}
|
||||
// if err := addTool(server, logger, &mcp.Tool{
|
||||
// Name: "add_maintenance_task",
|
||||
// Description: "Create a recurring or one-time home maintenance task.",
|
||||
// }, toolSet.Maintenance.AddTask); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if err := addTool(server, logger, &mcp.Tool{
|
||||
// Name: "log_maintenance",
|
||||
// Description: "Log completed maintenance; updates next due date.",
|
||||
// }, toolSet.Maintenance.LogWork); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if err := addTool(server, logger, &mcp.Tool{
|
||||
// Name: "get_upcoming_maintenance",
|
||||
// Description: "List maintenance tasks due within the next N days.",
|
||||
// }, toolSet.Maintenance.GetUpcoming); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if err := addTool(server, logger, &mcp.Tool{
|
||||
// Name: "search_maintenance_history",
|
||||
// Description: "Search the maintenance log by task name, category, or date range.",
|
||||
// }, toolSet.Maintenance.SearchHistory); err != nil {
|
||||
// return err
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -460,7 +556,7 @@ func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet T
|
||||
func registerDescribeTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "describe_tools",
|
||||
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, chat, meta.",
|
||||
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, plans, chat, meta.",
|
||||
}, toolSet.Describe.Describe); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -506,6 +602,23 @@ func BuildToolCatalog() []tools.ToolEntry {
|
||||
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
|
||||
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
|
||||
|
||||
// plans
|
||||
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
|
||||
{Name: "get_plan", Description: "Retrieve a full plan including dependencies (depends_on/blocks), related plans, linked skills, and guardrails.", Category: "plans"},
|
||||
{Name: "update_plan", Description: "Partially update a plan; only provided fields are changed. Use mark_reviewed to stamp last_reviewed_at.", Category: "plans"},
|
||||
{Name: "delete_plan", Description: "Hard-delete a plan by id.", Category: "plans"},
|
||||
{Name: "list_plans", Description: "List plans with optional filters: project, status, priority, owner, tag, and full-text query.", Category: "plans"},
|
||||
{Name: "add_plan_dependency", Description: "Declare that plan_id cannot proceed until depends_on_plan_id is complete.", Category: "plans"},
|
||||
{Name: "remove_plan_dependency", Description: "Remove a directional dependency between two plans.", Category: "plans"},
|
||||
{Name: "add_related_plan", Description: "Link two plans as thematically related (bidirectional, order-independent).", Category: "plans"},
|
||||
{Name: "remove_related_plan", Description: "Unlink two related plans.", Category: "plans"},
|
||||
{Name: "add_plan_skill", Description: "Link an agent skill to a plan so it is loaded with the plan's context.", Category: "plans"},
|
||||
{Name: "remove_plan_skill", Description: "Unlink an agent skill from a plan.", Category: "plans"},
|
||||
{Name: "list_plan_skills", Description: "List all skills linked to a plan.", Category: "plans"},
|
||||
{Name: "add_plan_guardrail", Description: "Link an agent guardrail to a plan so it applies during plan execution.", Category: "plans"},
|
||||
{Name: "remove_plan_guardrail", Description: "Unlink an agent guardrail from a plan.", Category: "plans"},
|
||||
{Name: "list_plan_guardrails", Description: "List all guardrails linked to a plan.", Category: "plans"},
|
||||
|
||||
// files
|
||||
{Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Use content_path (absolute server-side path, no size limit) for large or binary files, or content_base64 (≤10 MB) for small files. Pass thought_id/project to link immediately, or omit and pass the URI to save_file later.", Category: "files"},
|
||||
{Name: "save_file", Description: "Store a file and optionally link it to a thought. Use content_base64 (≤10 MB) for small files, or content_uri (amcs://files/{id} from a prior upload_file) for previously staged files. For files larger than 10 MB, use upload_file with content_path first. If the goal is to retain the artifact, store the file directly instead of reading or summarising it first.", Category: "files"},
|
||||
@@ -544,7 +657,7 @@ func BuildToolCatalog() []tools.ToolEntry {
|
||||
{Name: "delete_chat_history", Description: "Permanently delete a saved chat history by id.", Category: "chat"},
|
||||
|
||||
// meta
|
||||
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, chat, meta.", Category: "meta"},
|
||||
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, plans, chat, meta.", Category: "meta"},
|
||||
{Name: "annotate_tool", Description: "Persist usage notes, gotchas, or workflow patterns for a specific tool. Notes survive across sessions and are returned by describe_tools. Call this whenever you discover something non-obvious about a tool's behaviour. Pass an empty string to clear notes.", Category: "meta"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func streamableTestToolSet() ToolSet {
|
||||
Backfill: new(tools.BackfillTool),
|
||||
Reparse: new(tools.ReparseMetadataTool),
|
||||
RetryMetadata: new(tools.RetryEnrichmentTool),
|
||||
Maintenance: new(tools.MaintenanceTool),
|
||||
Skills: new(tools.SkillsTool),
|
||||
//Maintenance: new(tools.MaintenanceTool),
|
||||
Skills: new(tools.SkillsTool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,206 +1,206 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
// "github.com/google/uuid"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into family_members (name, relationship, birth_date, notes)
|
||||
values ($1, $2, $3, $4)
|
||||
returning id, created_at
|
||||
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
||||
// func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into family_members (name, relationship, birth_date, notes)
|
||||
// values ($1, $2, $3, $4)
|
||||
// returning id, created_at
|
||||
// `, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
||||
|
||||
created := m
|
||||
var model generatedmodels.ModelPublicFamilyMembers
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := m
|
||||
// var model generatedmodels.ModelPublicFamilyMembers
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
|
||||
rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list family members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
|
||||
// rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("list family members: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var members []ext.FamilyMember
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicFamilyMembers
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Relationship, &model.BirthDate, &model.Notes, &model.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan family member: %w", err)
|
||||
}
|
||||
members = append(members, familyMemberFromModel(model))
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
// var members []ext.FamilyMember
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicFamilyMembers
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.Relationship, &model.BirthDate, &model.Notes, &model.CreatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan family member: %w", err)
|
||||
// }
|
||||
// members = append(members, familyMemberFromModel(model))
|
||||
// }
|
||||
// return members, rows.Err()
|
||||
// }
|
||||
|
||||
func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
|
||||
values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
|
||||
returning id, created_at
|
||||
`, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
|
||||
nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
|
||||
nullStr(a.Location), nullStr(a.Notes))
|
||||
// func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
|
||||
// values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
|
||||
// returning id, created_at
|
||||
// `, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
|
||||
// nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
|
||||
// nullStr(a.Location), nullStr(a.Notes))
|
||||
|
||||
created := a
|
||||
var model generatedmodels.ModelPublicActivities
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := a
|
||||
// var model generatedmodels.ModelPublicActivities
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
|
||||
weekEnd := weekStart.AddDate(0, 0, 7)
|
||||
// func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
|
||||
// weekEnd := weekStart.AddDate(0, 0, 7)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
from activities a
|
||||
left join family_members fm on fm.id = a.family_member_id
|
||||
where (a.start_date >= $1 and a.start_date < $2)
|
||||
or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
|
||||
order by a.start_date, a.start_time
|
||||
`, weekStart, weekEnd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get week schedule: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
// a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
// a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
// from activities a
|
||||
// left join family_members fm on fm.id = a.family_member_id
|
||||
// where (a.start_date >= $1 and a.start_date < $2)
|
||||
// or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
|
||||
// order by a.start_date, a.start_time
|
||||
// `, weekStart, weekEnd)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("get week schedule: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
return scanActivities(rows)
|
||||
}
|
||||
// return scanActivities(rows)
|
||||
// }
|
||||
|
||||
func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
// func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
|
||||
// args := []any{}
|
||||
// conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if t := strings.TrimSpace(activityType); t != "" {
|
||||
args = append(args, t)
|
||||
conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
|
||||
}
|
||||
if memberID != nil {
|
||||
args = append(args, *memberID)
|
||||
conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
|
||||
}
|
||||
// if q := strings.TrimSpace(query); q != "" {
|
||||
// args = append(args, "%"+q+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
|
||||
// }
|
||||
// if t := strings.TrimSpace(activityType); t != "" {
|
||||
// args = append(args, t)
|
||||
// conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
|
||||
// }
|
||||
// if memberID != nil {
|
||||
// args = append(args, *memberID)
|
||||
// conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
|
||||
// }
|
||||
|
||||
q := `
|
||||
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
from activities a
|
||||
left join family_members fm on fm.id = a.family_member_id
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by a.start_date, a.start_time"
|
||||
// q := `
|
||||
// select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
// a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
// a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
// from activities a
|
||||
// left join family_members fm on fm.id = a.family_member_id
|
||||
// `
|
||||
// if len(conditions) > 0 {
|
||||
// q += " where " + strings.Join(conditions, " and ")
|
||||
// }
|
||||
// q += " order by a.start_date, a.start_time"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search activities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("search activities: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
return scanActivities(rows)
|
||||
}
|
||||
// return scanActivities(rows)
|
||||
// }
|
||||
|
||||
func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at
|
||||
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
||||
// func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
|
||||
// values ($1, $2, $3, $4, $5, $6)
|
||||
// returning id, created_at
|
||||
// `, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
||||
|
||||
created := d
|
||||
var model generatedmodels.ModelPublicImportantDates
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := d
|
||||
// var model generatedmodels.ModelPublicImportantDates
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 30
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.AddDate(0, 0, daysAhead)
|
||||
// func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
|
||||
// if daysAhead <= 0 {
|
||||
// daysAhead = 30
|
||||
// }
|
||||
// now := time.Now()
|
||||
// cutoff := now.AddDate(0, 0, daysAhead)
|
||||
|
||||
// For yearly recurring events, check if this year's occurrence falls in range
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select d.id, d.family_member_id, fm.name, d.title, d.date_value,
|
||||
d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
|
||||
from important_dates d
|
||||
left join family_members fm on fm.id = d.family_member_id
|
||||
where (
|
||||
(d.recurring_yearly = false and d.date_value between $1 and $2)
|
||||
or
|
||||
(d.recurring_yearly = true and
|
||||
make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
|
||||
between $1 and $2)
|
||||
)
|
||||
order by d.date_value
|
||||
`, now, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upcoming dates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// // For yearly recurring events, check if this year's occurrence falls in range
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select d.id, d.family_member_id, fm.name, d.title, d.date_value,
|
||||
// d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
|
||||
// from important_dates d
|
||||
// left join family_members fm on fm.id = d.family_member_id
|
||||
// where (
|
||||
// (d.recurring_yearly = false and d.date_value between $1 and $2)
|
||||
// or
|
||||
// (d.recurring_yearly = true and
|
||||
// make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
|
||||
// between $1 and $2)
|
||||
// )
|
||||
// order by d.date_value
|
||||
// `, now, cutoff)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("get upcoming dates: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var dates []ext.ImportantDate
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicImportantDates
|
||||
var memberName *string
|
||||
if err := rows.Scan(&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.DateValue,
|
||||
&model.RecurringYearly, &model.ReminderDaysBefore, &model.Notes, &model.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan important date: %w", err)
|
||||
}
|
||||
dates = append(dates, importantDateFromModel(model, strVal(memberName)))
|
||||
}
|
||||
return dates, rows.Err()
|
||||
}
|
||||
// var dates []ext.ImportantDate
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicImportantDates
|
||||
// var memberName *string
|
||||
// if err := rows.Scan(&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.DateValue,
|
||||
// &model.RecurringYearly, &model.ReminderDaysBefore, &model.Notes, &model.CreatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan important date: %w", err)
|
||||
// }
|
||||
// dates = append(dates, importantDateFromModel(model, strVal(memberName)))
|
||||
// }
|
||||
// return dates, rows.Err()
|
||||
// }
|
||||
|
||||
func scanActivities(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.Activity, error) {
|
||||
defer rows.Close()
|
||||
var activities []ext.Activity
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicActivities
|
||||
var memberName *string
|
||||
if err := rows.Scan(
|
||||
&model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.ActivityType,
|
||||
&model.DayOfWeek, &model.StartTime, &model.EndTime,
|
||||
&model.StartDate, &model.EndDate, &model.Location, &model.Notes, &model.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan activity: %w", err)
|
||||
}
|
||||
activities = append(activities, activityFromModel(model, strVal(memberName)))
|
||||
}
|
||||
return activities, rows.Err()
|
||||
}
|
||||
// func scanActivities(rows interface {
|
||||
// Next() bool
|
||||
// Scan(...any) error
|
||||
// Err() error
|
||||
// Close()
|
||||
// }) ([]ext.Activity, error) {
|
||||
// defer rows.Close()
|
||||
// var activities []ext.Activity
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicActivities
|
||||
// var memberName *string
|
||||
// if err := rows.Scan(
|
||||
// &model.ID, &model.FamilyMemberID, &memberName, &model.Title, &model.ActivityType,
|
||||
// &model.DayOfWeek, &model.StartTime, &model.EndTime,
|
||||
// &model.StartDate, &model.EndDate, &model.Location, &model.Notes, &model.CreatedAt,
|
||||
// ); err != nil {
|
||||
// return nil, fmt.Errorf("scan activity: %w", err)
|
||||
// }
|
||||
// activities = append(activities, activityFromModel(model, strVal(memberName)))
|
||||
// }
|
||||
// return activities, rows.Err()
|
||||
// }
|
||||
|
||||
@@ -1,235 +1,235 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
// "github.com/google/uuid"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
// func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
|
||||
// if c.Tags == nil {
|
||||
// c.Tags = []string{}
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
returning id, created_at, updated_at
|
||||
`, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
|
||||
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
|
||||
// values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
// returning id, created_at, updated_at
|
||||
// `, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
|
||||
// nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
||||
|
||||
created := c
|
||||
var model generatedmodels.ModelPublicProfessionalContacts
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
created.UpdatedAt = model.UpdatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := c
|
||||
// var model generatedmodels.ModelPublicProfessionalContacts
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// created.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
// func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
|
||||
// args := []any{}
|
||||
// conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
idx := len(args)
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
args = append(args, tags)
|
||||
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
}
|
||||
// if q := strings.TrimSpace(query); q != "" {
|
||||
// args = append(args, "%"+q+"%")
|
||||
// idx := len(args)
|
||||
// conditions = append(conditions, fmt.Sprintf(
|
||||
// "(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
|
||||
// }
|
||||
// if len(tags) > 0 {
|
||||
// args = append(args, tags)
|
||||
// conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
// }
|
||||
|
||||
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
// q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
||||
// if len(conditions) > 0 {
|
||||
// q += " where " + strings.Join(conditions, " and ")
|
||||
// }
|
||||
// q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search contacts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("search contacts: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
return scanContacts(rows)
|
||||
}
|
||||
// return scanContacts(rows)
|
||||
// }
|
||||
|
||||
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
from professional_contacts where id = $1
|
||||
`, id)
|
||||
// func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
// from professional_contacts where id = $1
|
||||
// `, id)
|
||||
|
||||
var model generatedmodels.ModelPublicProfessionalContacts
|
||||
var tags []string
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||
&model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||
}
|
||||
c := professionalContactFromModel(model, tags)
|
||||
return c, nil
|
||||
}
|
||||
// var model generatedmodels.ModelPublicProfessionalContacts
|
||||
// var tags []string
|
||||
// if err := row.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||
// &model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||
// &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||
// }
|
||||
// c := professionalContactFromModel(model, tags)
|
||||
// return c, nil
|
||||
// }
|
||||
|
||||
func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
|
||||
occurredAt := interaction.OccurredAt
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
// func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
|
||||
// occurredAt := interaction.OccurredAt
|
||||
// if occurredAt.IsZero() {
|
||||
// occurredAt = time.Now()
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at
|
||||
`, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
|
||||
interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
|
||||
// values ($1, $2, $3, $4, $5, $6)
|
||||
// returning id, created_at
|
||||
// `, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
|
||||
// interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
|
||||
|
||||
created := interaction
|
||||
created.OccurredAt = occurredAt
|
||||
var model generatedmodels.ModelPublicContactInteractions
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := interaction
|
||||
// created.OccurredAt = occurredAt
|
||||
// var model generatedmodels.ModelPublicContactInteractions
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
|
||||
contact, err := db.GetContact(ctx, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
// func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
|
||||
// contact, err := db.GetContact(ctx, contactID)
|
||||
// if err != nil {
|
||||
// return ext.ContactHistory{}, err
|
||||
// }
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
|
||||
from contact_interactions where contact_id = $1 order by occurred_at desc
|
||||
`, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
|
||||
// from contact_interactions where contact_id = $1 order by occurred_at desc
|
||||
// `, contactID)
|
||||
// if err != nil {
|
||||
// return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var interactions []ext.ContactInteraction
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicContactInteractions
|
||||
if err := rows.Scan(&model.ID, &model.ContactID, &model.InteractionType, &model.OccurredAt, &model.Summary,
|
||||
&model.FollowUpNeeded, &model.FollowUpNotes, &model.CreatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||
}
|
||||
interactions = append(interactions, contactInteractionFromModel(model))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
// var interactions []ext.ContactInteraction
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicContactInteractions
|
||||
// if err := rows.Scan(&model.ID, &model.ContactID, &model.InteractionType, &model.OccurredAt, &model.Summary,
|
||||
// &model.FollowUpNeeded, &model.FollowUpNotes, &model.CreatedAt); err != nil {
|
||||
// return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||
// }
|
||||
// interactions = append(interactions, contactInteractionFromModel(model))
|
||||
// }
|
||||
// if err := rows.Err(); err != nil {
|
||||
// return ext.ContactHistory{}, err
|
||||
// }
|
||||
|
||||
oppRows, err := db.pool.Query(ctx, `
|
||||
select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
|
||||
from opportunities where contact_id = $1 order by created_at desc
|
||||
`, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
|
||||
}
|
||||
defer oppRows.Close()
|
||||
// oppRows, err := db.pool.Query(ctx, `
|
||||
// select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
|
||||
// from opportunities where contact_id = $1 order by created_at desc
|
||||
// `, contactID)
|
||||
// if err != nil {
|
||||
// return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
|
||||
// }
|
||||
// defer oppRows.Close()
|
||||
|
||||
var opportunities []ext.Opportunity
|
||||
for oppRows.Next() {
|
||||
var model generatedmodels.ModelPublicOpportunities
|
||||
if err := oppRows.Scan(&model.ID, &model.ContactID, &model.Title, &model.Description, &model.Stage, &model.Value,
|
||||
&model.ExpectedCloseDate, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||
}
|
||||
opportunities = append(opportunities, opportunityFromModel(model))
|
||||
}
|
||||
if err := oppRows.Err(); err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
// var opportunities []ext.Opportunity
|
||||
// for oppRows.Next() {
|
||||
// var model generatedmodels.ModelPublicOpportunities
|
||||
// if err := oppRows.Scan(&model.ID, &model.ContactID, &model.Title, &model.Description, &model.Stage, &model.Value,
|
||||
// &model.ExpectedCloseDate, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||
// }
|
||||
// opportunities = append(opportunities, opportunityFromModel(model))
|
||||
// }
|
||||
// if err := oppRows.Err(); err != nil {
|
||||
// return ext.ContactHistory{}, err
|
||||
// }
|
||||
|
||||
return ext.ContactHistory{
|
||||
Contact: contact,
|
||||
Interactions: interactions,
|
||||
Opportunities: opportunities,
|
||||
}, nil
|
||||
}
|
||||
// return ext.ContactHistory{
|
||||
// Contact: contact,
|
||||
// Interactions: interactions,
|
||||
// Opportunities: opportunities,
|
||||
// }, nil
|
||||
// }
|
||||
|
||||
func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
returning id, created_at, updated_at
|
||||
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
||||
// func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
|
||||
// values ($1, $2, $3, $4, $5, $6, $7)
|
||||
// returning id, created_at, updated_at
|
||||
// `, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
||||
|
||||
created := o
|
||||
var model generatedmodels.ModelPublicOpportunities
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
created.UpdatedAt = model.UpdatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := o
|
||||
// var model generatedmodels.ModelPublicOpportunities
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// created.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 7
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, daysAhead)
|
||||
// func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
|
||||
// if daysAhead <= 0 {
|
||||
// daysAhead = 7
|
||||
// }
|
||||
// cutoff := time.Now().AddDate(0, 0, daysAhead)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
from professional_contacts
|
||||
where follow_up_date <= $1
|
||||
order by follow_up_date asc
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get follow-ups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select id, name, company, title, email, phone, linkedin_url, how_we_met, tags::text[], notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
// from professional_contacts
|
||||
// where follow_up_date <= $1
|
||||
// order by follow_up_date asc
|
||||
// `, cutoff)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("get follow-ups: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
return scanContacts(rows)
|
||||
}
|
||||
// return scanContacts(rows)
|
||||
// }
|
||||
|
||||
func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
update professional_contacts
|
||||
set notes = coalesce(notes, '') || $2
|
||||
where id = $1
|
||||
`, contactID, thoughtContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append thought to contact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
|
||||
// _, err := db.pool.Exec(ctx, `
|
||||
// update professional_contacts
|
||||
// set notes = coalesce(notes, '') || $2
|
||||
// where id = $1
|
||||
// `, contactID, thoughtContent)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("append thought to contact: %w", err)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func scanContacts(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.ProfessionalContact, error) {
|
||||
defer rows.Close()
|
||||
var contacts []ext.ProfessionalContact
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicProfessionalContacts
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||
&model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||
&model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan contact: %w", err)
|
||||
}
|
||||
contacts = append(contacts, professionalContactFromModel(model, tags))
|
||||
}
|
||||
return contacts, rows.Err()
|
||||
}
|
||||
// func scanContacts(rows interface {
|
||||
// Next() bool
|
||||
// Scan(...any) error
|
||||
// Err() error
|
||||
// Close()
|
||||
// }) ([]ext.ProfessionalContact, error) {
|
||||
// defer rows.Close()
|
||||
// var contacts []ext.ProfessionalContact
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicProfessionalContacts
|
||||
// var tags []string
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.Company, &model.Title, &model.Email, &model.Phone,
|
||||
// &model.LinkedinURL, &model.HowWeMet, &tags, &model.Notes, &model.LastContacted, &model.FollowUpDate,
|
||||
// &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan contact: %w", err)
|
||||
// }
|
||||
// contacts = append(contacts, professionalContactFromModel(model, tags))
|
||||
// }
|
||||
// return contacts, rows.Err()
|
||||
// }
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
// import (
|
||||
// "context"
|
||||
// "encoding/json"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
// "github.com/google/uuid"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
|
||||
details, err := json.Marshal(item.Details)
|
||||
if err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
|
||||
}
|
||||
// func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
|
||||
// details, err := json.Marshal(item.Details)
|
||||
// if err != nil {
|
||||
// return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into household_items (name, category, location, details, notes)
|
||||
values ($1, $2, $3, $4::jsonb, $5)
|
||||
returning id, created_at, updated_at
|
||||
`, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into household_items (name, category, location, details, notes)
|
||||
// values ($1, $2, $3, $4::jsonb, $5)
|
||||
// returning id, created_at, updated_at
|
||||
// `, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
|
||||
|
||||
created := item
|
||||
var model generatedmodels.ModelPublicHouseholdItems
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
created.UpdatedAt = model.UpdatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := item
|
||||
// var model generatedmodels.ModelPublicHouseholdItems
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// created.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
// func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
|
||||
// args := []any{}
|
||||
// conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(category); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
|
||||
}
|
||||
if l := strings.TrimSpace(location); l != "" {
|
||||
args = append(args, "%"+l+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
|
||||
}
|
||||
// if q := strings.TrimSpace(query); q != "" {
|
||||
// args = append(args, "%"+q+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
|
||||
// }
|
||||
// if c := strings.TrimSpace(category); c != "" {
|
||||
// args = append(args, c)
|
||||
// conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
|
||||
// }
|
||||
// if l := strings.TrimSpace(location); l != "" {
|
||||
// args = append(args, "%"+l+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
|
||||
// }
|
||||
|
||||
q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
// q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
|
||||
// if len(conditions) > 0 {
|
||||
// q += " where " + strings.Join(conditions, " and ")
|
||||
// }
|
||||
// q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search household items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("search household items: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var items []ext.HouseholdItem
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicHouseholdItems
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan household item: %w", err)
|
||||
}
|
||||
items = append(items, householdItemFromModel(model))
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
// var items []ext.HouseholdItem
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicHouseholdItems
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan household item: %w", err)
|
||||
// }
|
||||
// items = append(items, householdItemFromModel(model))
|
||||
// }
|
||||
// return items, rows.Err()
|
||||
// }
|
||||
|
||||
func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, category, location, details, notes, created_at, updated_at
|
||||
from household_items where id = $1
|
||||
`, id)
|
||||
// func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// select id, name, category, location, details, notes, created_at, updated_at
|
||||
// from household_items where id = $1
|
||||
// `, id)
|
||||
|
||||
var model generatedmodels.ModelPublicHouseholdItems
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
|
||||
}
|
||||
return householdItemFromModel(model), nil
|
||||
}
|
||||
// var model generatedmodels.ModelPublicHouseholdItems
|
||||
// if err := row.Scan(&model.ID, &model.Name, &model.Category, &model.Location, &model.Details, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
|
||||
// }
|
||||
// return householdItemFromModel(model), nil
|
||||
// }
|
||||
|
||||
func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
returning id, created_at
|
||||
`, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
|
||||
nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
|
||||
// func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
|
||||
// values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
// returning id, created_at
|
||||
// `, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
|
||||
// nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
|
||||
|
||||
created := v
|
||||
var model generatedmodels.ModelPublicHouseholdVendors
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := v
|
||||
// var model generatedmodels.ModelPublicHouseholdVendors
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
|
||||
args := []any{}
|
||||
q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
|
||||
if st := strings.TrimSpace(serviceType); st != "" {
|
||||
args = append(args, st)
|
||||
q += " where service_type = $1"
|
||||
}
|
||||
q += " order by name"
|
||||
// func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
|
||||
// args := []any{}
|
||||
// q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
|
||||
// if st := strings.TrimSpace(serviceType); st != "" {
|
||||
// args = append(args, st)
|
||||
// q += " where service_type = $1"
|
||||
// }
|
||||
// q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vendors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("list vendors: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var vendors []ext.HouseholdVendor
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicHouseholdVendors
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.ServiceType, &model.Phone, &model.Email, &model.Website, &model.Notes, &model.Rating, &model.LastUsed, &model.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan vendor: %w", err)
|
||||
}
|
||||
vendors = append(vendors, householdVendorFromModel(model))
|
||||
}
|
||||
return vendors, rows.Err()
|
||||
}
|
||||
// var vendors []ext.HouseholdVendor
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicHouseholdVendors
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.ServiceType, &model.Phone, &model.Email, &model.Website, &model.Notes, &model.Rating, &model.LastUsed, &model.CreatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan vendor: %w", err)
|
||||
// }
|
||||
// vendors = append(vendors, householdVendorFromModel(model))
|
||||
// }
|
||||
// return vendors, rows.Err()
|
||||
// }
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at, updated_at
|
||||
`, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
|
||||
// func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
|
||||
// values ($1, $2, $3, $4, $5, $6)
|
||||
// returning id, created_at, updated_at
|
||||
// `, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
|
||||
|
||||
created := t
|
||||
var model generatedmodels.ModelPublicMaintenanceTasks
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
created.UpdatedAt = model.UpdatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := t
|
||||
// var model generatedmodels.ModelPublicMaintenanceTasks
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// created.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
|
||||
completedAt := log.CompletedAt
|
||||
if completedAt.IsZero() {
|
||||
completedAt = time.Now()
|
||||
}
|
||||
// func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
|
||||
// completedAt := log.CompletedAt
|
||||
// if completedAt.IsZero() {
|
||||
// completedAt = time.Now()
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id
|
||||
`, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
|
||||
// values ($1, $2, $3, $4, $5, $6)
|
||||
// returning id
|
||||
// `, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
|
||||
|
||||
created := log
|
||||
created.CompletedAt = completedAt
|
||||
var model generatedmodels.ModelPublicMaintenanceLogs
|
||||
if err := row.Scan(&model.ID); err != nil {
|
||||
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
return created, nil
|
||||
}
|
||||
// created := log
|
||||
// created.CompletedAt = completedAt
|
||||
// var model generatedmodels.ModelPublicMaintenanceLogs
|
||||
// if err := row.Scan(&model.ID); err != nil {
|
||||
// return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 30
|
||||
}
|
||||
cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
|
||||
// func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
|
||||
// if daysAhead <= 0 {
|
||||
// daysAhead = 30
|
||||
// }
|
||||
// cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
|
||||
from maintenance_tasks
|
||||
where next_due <= $1 or next_due is null
|
||||
order by next_due asc nulls last, priority desc
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upcoming maintenance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
|
||||
// from maintenance_tasks
|
||||
// where next_due <= $1 or next_due is null
|
||||
// order by next_due asc nulls last, priority desc
|
||||
// `, cutoff)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("get upcoming maintenance: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
tasks := make([]ext.MaintenanceTask, 0)
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicMaintenanceTasks
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.FrequencyDays, &model.LastCompleted, &model.NextDue, &model.Priority, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance task: %w", err)
|
||||
}
|
||||
tasks = append(tasks, maintenanceTaskFromModel(model))
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
// tasks := make([]ext.MaintenanceTask, 0)
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicMaintenanceTasks
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.Category, &model.FrequencyDays, &model.LastCompleted, &model.NextDue, &model.Priority, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan maintenance task: %w", err)
|
||||
// }
|
||||
// tasks = append(tasks, maintenanceTaskFromModel(model))
|
||||
// }
|
||||
// return tasks, rows.Err()
|
||||
// }
|
||||
|
||||
func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
// func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
|
||||
// args := []any{}
|
||||
// conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(category); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
|
||||
}
|
||||
if start != nil {
|
||||
args = append(args, *start)
|
||||
conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
|
||||
}
|
||||
if end != nil {
|
||||
args = append(args, *end)
|
||||
conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
|
||||
}
|
||||
// if q := strings.TrimSpace(query); q != "" {
|
||||
// args = append(args, "%"+q+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
|
||||
// }
|
||||
// if c := strings.TrimSpace(category); c != "" {
|
||||
// args = append(args, c)
|
||||
// conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
|
||||
// }
|
||||
// if start != nil {
|
||||
// args = append(args, *start)
|
||||
// conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
|
||||
// }
|
||||
// if end != nil {
|
||||
// args = append(args, *end)
|
||||
// conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
|
||||
// }
|
||||
|
||||
q := `
|
||||
select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
|
||||
mt.name, mt.category
|
||||
from maintenance_logs ml
|
||||
join maintenance_tasks mt on mt.id = ml.task_id
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by ml.completed_at desc"
|
||||
// q := `
|
||||
// select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
|
||||
// mt.name, mt.category
|
||||
// from maintenance_logs ml
|
||||
// join maintenance_tasks mt on mt.id = ml.task_id
|
||||
// `
|
||||
// if len(conditions) > 0 {
|
||||
// q += " where " + strings.Join(conditions, " and ")
|
||||
// }
|
||||
// q += " order by ml.completed_at desc"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search maintenance history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("search maintenance history: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var logs []ext.MaintenanceLogWithTask
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicMaintenanceLogs
|
||||
var taskName, taskCategory string
|
||||
if err := rows.Scan(
|
||||
&model.ID, &model.TaskID, &model.CompletedAt, &model.PerformedBy, &model.Cost, &model.Notes, &model.NextAction,
|
||||
&taskName, &taskCategory,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance log: %w", err)
|
||||
}
|
||||
l := ext.MaintenanceLogWithTask{
|
||||
MaintenanceLog: maintenanceLogFromModel(model),
|
||||
TaskName: taskName,
|
||||
TaskCategory: taskCategory,
|
||||
}
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
// var logs []ext.MaintenanceLogWithTask
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicMaintenanceLogs
|
||||
// var taskName, taskCategory string
|
||||
// if err := rows.Scan(
|
||||
// &model.ID, &model.TaskID, &model.CompletedAt, &model.PerformedBy, &model.Cost, &model.Notes, &model.NextAction,
|
||||
// &taskName, &taskCategory,
|
||||
// ); err != nil {
|
||||
// return nil, fmt.Errorf("scan maintenance log: %w", err)
|
||||
// }
|
||||
// l := ext.MaintenanceLogWithTask{
|
||||
// MaintenanceLog: maintenanceLogFromModel(model),
|
||||
// TaskName: taskName,
|
||||
// TaskCategory: taskCategory,
|
||||
// }
|
||||
// logs = append(logs, l)
|
||||
// }
|
||||
// return logs, rows.Err()
|
||||
// }
|
||||
|
||||
@@ -1,280 +1,280 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "encoding/json"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
// "github.com/google/uuid"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
|
||||
ingredients, err := json.Marshal(r.Ingredients)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
}
|
||||
instructions, err := json.Marshal(r.Instructions)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
}
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
// func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
|
||||
// ingredients, err := json.Marshal(r.Ingredients)
|
||||
// if err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
// }
|
||||
// instructions, err := json.Marshal(r.Instructions)
|
||||
// if err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
// }
|
||||
// if r.Tags == nil {
|
||||
// r.Tags = []string{}
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
|
||||
returning id, created_at, updated_at
|
||||
`, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
|
||||
// values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
|
||||
// returning id, created_at, updated_at
|
||||
// `, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
// ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
|
||||
created := r
|
||||
var model generatedmodels.ModelPublicRecipes
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
|
||||
}
|
||||
created.ID = model.ID.UUID()
|
||||
created.CreatedAt = model.CreatedAt.Time()
|
||||
created.UpdatedAt = model.UpdatedAt.Time()
|
||||
return created, nil
|
||||
}
|
||||
// created := r
|
||||
// var model generatedmodels.ModelPublicRecipes
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
|
||||
// }
|
||||
// created.ID = model.ID.UUID()
|
||||
// created.CreatedAt = model.CreatedAt.Time()
|
||||
// created.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return created, nil
|
||||
// }
|
||||
|
||||
func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
// func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
|
||||
// args := []any{}
|
||||
// conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(cuisine); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
args = append(args, tags)
|
||||
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
}
|
||||
if ing := strings.TrimSpace(ingredient); ing != "" {
|
||||
args = append(args, "%"+ing+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
|
||||
}
|
||||
// if q := strings.TrimSpace(query); q != "" {
|
||||
// args = append(args, "%"+q+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
|
||||
// }
|
||||
// if c := strings.TrimSpace(cuisine); c != "" {
|
||||
// args = append(args, c)
|
||||
// conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
|
||||
// }
|
||||
// if len(tags) > 0 {
|
||||
// args = append(args, tags)
|
||||
// conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
// }
|
||||
// if ing := strings.TrimSpace(ingredient); ing != "" {
|
||||
// args = append(args, "%"+ing+"%")
|
||||
// conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
|
||||
// }
|
||||
|
||||
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at from recipes`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
// q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at from recipes`
|
||||
// if len(conditions) > 0 {
|
||||
// q += " where " + strings.Join(conditions, " and ")
|
||||
// }
|
||||
// q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// rows, err := db.pool.Query(ctx, q, args...)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("search recipes: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var recipes []ext.Recipe
|
||||
for rows.Next() {
|
||||
r, err := scanRecipeRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipes = append(recipes, r)
|
||||
}
|
||||
return recipes, rows.Err()
|
||||
}
|
||||
// var recipes []ext.Recipe
|
||||
// for rows.Next() {
|
||||
// r, err := scanRecipeRow(rows)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// recipes = append(recipes, r)
|
||||
// }
|
||||
// return recipes, rows.Err()
|
||||
// }
|
||||
|
||||
func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at
|
||||
from recipes where id = $1
|
||||
`, id)
|
||||
// func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags::text[], rating, notes, created_at, updated_at
|
||||
// from recipes where id = $1
|
||||
// `, id)
|
||||
|
||||
var model generatedmodels.ModelPublicRecipes
|
||||
var tags []string
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
|
||||
&model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return recipeFromModel(model, tags), nil
|
||||
}
|
||||
// var model generatedmodels.ModelPublicRecipes
|
||||
// var tags []string
|
||||
// if err := row.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
|
||||
// &model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
|
||||
// }
|
||||
// if tags == nil {
|
||||
// tags = []string{}
|
||||
// }
|
||||
// return recipeFromModel(model, tags), nil
|
||||
// }
|
||||
|
||||
func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
|
||||
ingredients, err := json.Marshal(r.Ingredients)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
}
|
||||
instructions, err := json.Marshal(r.Instructions)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
}
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
// func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
|
||||
// ingredients, err := json.Marshal(r.Ingredients)
|
||||
// if err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
// }
|
||||
// instructions, err := json.Marshal(r.Instructions)
|
||||
// if err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
// }
|
||||
// if r.Tags == nil {
|
||||
// r.Tags = []string{}
|
||||
// }
|
||||
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
update recipes set
|
||||
name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
|
||||
servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
|
||||
tags = $9, rating = $10, notes = $11
|
||||
where id = $1
|
||||
`, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
|
||||
}
|
||||
return db.GetRecipe(ctx, id)
|
||||
}
|
||||
// _, err = db.pool.Exec(ctx, `
|
||||
// update recipes set
|
||||
// name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
|
||||
// servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
|
||||
// tags = $9, rating = $10, notes = $11
|
||||
// where id = $1
|
||||
// `, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
// ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
// if err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
|
||||
// }
|
||||
// return db.GetRecipe(ctx, id)
|
||||
// }
|
||||
|
||||
func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
|
||||
if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
|
||||
return nil, fmt.Errorf("clear meal plan: %w", err)
|
||||
}
|
||||
// func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
|
||||
// if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
|
||||
// return nil, fmt.Errorf("clear meal plan: %w", err)
|
||||
// }
|
||||
|
||||
var results []ext.MealPlanEntry
|
||||
for _, e := range entries {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
returning id, created_at
|
||||
`, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
|
||||
// var results []ext.MealPlanEntry
|
||||
// for _, e := range entries {
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
|
||||
// values ($1, $2, $3, $4, $5, $6, $7)
|
||||
// returning id, created_at
|
||||
// `, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
|
||||
|
||||
entry := ext.MealPlanEntry{
|
||||
WeekStart: weekStart,
|
||||
DayOfWeek: e.DayOfWeek,
|
||||
MealType: e.MealType,
|
||||
RecipeID: e.RecipeID,
|
||||
CustomMeal: e.CustomMeal,
|
||||
Servings: e.Servings,
|
||||
Notes: e.Notes,
|
||||
}
|
||||
var model generatedmodels.ModelPublicMealPlans
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("insert meal plan entry: %w", err)
|
||||
}
|
||||
entry.ID = model.ID.UUID()
|
||||
entry.CreatedAt = model.CreatedAt.Time()
|
||||
results = append(results, entry)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
// entry := ext.MealPlanEntry{
|
||||
// WeekStart: weekStart,
|
||||
// DayOfWeek: e.DayOfWeek,
|
||||
// MealType: e.MealType,
|
||||
// RecipeID: e.RecipeID,
|
||||
// CustomMeal: e.CustomMeal,
|
||||
// Servings: e.Servings,
|
||||
// Notes: e.Notes,
|
||||
// }
|
||||
// var model generatedmodels.ModelPublicMealPlans
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("insert meal plan entry: %w", err)
|
||||
// }
|
||||
// entry.ID = model.ID.UUID()
|
||||
// entry.CreatedAt = model.CreatedAt.Time()
|
||||
// results = append(results, entry)
|
||||
// }
|
||||
// return results, nil
|
||||
// }
|
||||
|
||||
func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
|
||||
from meal_plans mp
|
||||
left join recipes r on r.id = mp.recipe_id
|
||||
where mp.week_start = $1
|
||||
order by
|
||||
case mp.day_of_week
|
||||
when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
|
||||
when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
|
||||
when 'sunday' then 7 else 8
|
||||
end,
|
||||
case mp.meal_type
|
||||
when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
|
||||
when 'snack' then 4 else 5
|
||||
end
|
||||
`, weekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get meal plan: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
// func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
|
||||
// rows, err := db.pool.Query(ctx, `
|
||||
// select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
|
||||
// from meal_plans mp
|
||||
// left join recipes r on r.id = mp.recipe_id
|
||||
// where mp.week_start = $1
|
||||
// order by
|
||||
// case mp.day_of_week
|
||||
// when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
|
||||
// when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
|
||||
// when 'sunday' then 7 else 8
|
||||
// end,
|
||||
// case mp.meal_type
|
||||
// when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
|
||||
// when 'snack' then 4 else 5
|
||||
// end
|
||||
// `, weekStart)
|
||||
// if err != nil {
|
||||
// return nil, fmt.Errorf("get meal plan: %w", err)
|
||||
// }
|
||||
// defer rows.Close()
|
||||
|
||||
var entries []ext.MealPlanEntry
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicMealPlans
|
||||
var recipeName *string
|
||||
if err := rows.Scan(&model.ID, &model.WeekStart, &model.DayOfWeek, &model.MealType, &model.RecipeID, &recipeName, &model.CustomMeal, &model.Servings, &model.Notes, &model.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan meal plan entry: %w", err)
|
||||
}
|
||||
entries = append(entries, mealPlanEntryFromModel(model, strVal(recipeName)))
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
// var entries []ext.MealPlanEntry
|
||||
// for rows.Next() {
|
||||
// var model generatedmodels.ModelPublicMealPlans
|
||||
// var recipeName *string
|
||||
// if err := rows.Scan(&model.ID, &model.WeekStart, &model.DayOfWeek, &model.MealType, &model.RecipeID, &recipeName, &model.CustomMeal, &model.Servings, &model.Notes, &model.CreatedAt); err != nil {
|
||||
// return nil, fmt.Errorf("scan meal plan entry: %w", err)
|
||||
// }
|
||||
// entries = append(entries, mealPlanEntryFromModel(model, strVal(recipeName)))
|
||||
// }
|
||||
// return entries, rows.Err()
|
||||
// }
|
||||
|
||||
func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
|
||||
entries, err := db.GetMealPlan(ctx, weekStart)
|
||||
if err != nil {
|
||||
return ext.ShoppingList{}, err
|
||||
}
|
||||
// func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
|
||||
// entries, err := db.GetMealPlan(ctx, weekStart)
|
||||
// if err != nil {
|
||||
// return ext.ShoppingList{}, err
|
||||
// }
|
||||
|
||||
recipeIDs := map[uuid.UUID]bool{}
|
||||
for _, e := range entries {
|
||||
if e.RecipeID != nil {
|
||||
recipeIDs[*e.RecipeID] = true
|
||||
}
|
||||
}
|
||||
// recipeIDs := map[uuid.UUID]bool{}
|
||||
// for _, e := range entries {
|
||||
// if e.RecipeID != nil {
|
||||
// recipeIDs[*e.RecipeID] = true
|
||||
// }
|
||||
// }
|
||||
|
||||
aggregated := map[string]*ext.ShoppingItem{}
|
||||
for id := range recipeIDs {
|
||||
recipe, err := db.GetRecipe(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ing := range recipe.Ingredients {
|
||||
key := strings.ToLower(ing.Name)
|
||||
if existing, ok := aggregated[key]; ok {
|
||||
if ing.Quantity != "" {
|
||||
existing.Quantity += "+" + ing.Quantity
|
||||
}
|
||||
} else {
|
||||
recipeIDCopy := id
|
||||
aggregated[key] = &ext.ShoppingItem{
|
||||
Name: ing.Name,
|
||||
Quantity: ing.Quantity,
|
||||
Unit: ing.Unit,
|
||||
Purchased: false,
|
||||
RecipeID: &recipeIDCopy,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// aggregated := map[string]*ext.ShoppingItem{}
|
||||
// for id := range recipeIDs {
|
||||
// recipe, err := db.GetRecipe(ctx, id)
|
||||
// if err != nil {
|
||||
// continue
|
||||
// }
|
||||
// for _, ing := range recipe.Ingredients {
|
||||
// key := strings.ToLower(ing.Name)
|
||||
// if existing, ok := aggregated[key]; ok {
|
||||
// if ing.Quantity != "" {
|
||||
// existing.Quantity += "+" + ing.Quantity
|
||||
// }
|
||||
// } else {
|
||||
// recipeIDCopy := id
|
||||
// aggregated[key] = &ext.ShoppingItem{
|
||||
// Name: ing.Name,
|
||||
// Quantity: ing.Quantity,
|
||||
// Unit: ing.Unit,
|
||||
// Purchased: false,
|
||||
// RecipeID: &recipeIDCopy,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
items := make([]ext.ShoppingItem, 0, len(aggregated))
|
||||
for _, item := range aggregated {
|
||||
items = append(items, *item)
|
||||
}
|
||||
// items := make([]ext.ShoppingItem, 0, len(aggregated))
|
||||
// for _, item := range aggregated {
|
||||
// items = append(items, *item)
|
||||
// }
|
||||
|
||||
itemsJSON, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
|
||||
}
|
||||
// itemsJSON, err := json.Marshal(items)
|
||||
// if err != nil {
|
||||
// return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
|
||||
// }
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into shopping_lists (week_start, items)
|
||||
values ($1, $2::jsonb)
|
||||
on conflict (week_start) do update set items = excluded.items, updated_at = now()
|
||||
returning id, created_at, updated_at
|
||||
`, weekStart, itemsJSON)
|
||||
// row := db.pool.QueryRow(ctx, `
|
||||
// insert into shopping_lists (week_start, items)
|
||||
// values ($1, $2::jsonb)
|
||||
// on conflict (week_start) do update set items = excluded.items, updated_at = now()
|
||||
// returning id, created_at, updated_at
|
||||
// `, weekStart, itemsJSON)
|
||||
|
||||
var model generatedmodels.ModelPublicShoppingLists
|
||||
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
|
||||
if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
|
||||
}
|
||||
list.ID = model.ID.UUID()
|
||||
list.CreatedAt = model.CreatedAt.Time()
|
||||
list.UpdatedAt = model.UpdatedAt.Time()
|
||||
return list, nil
|
||||
}
|
||||
// var model generatedmodels.ModelPublicShoppingLists
|
||||
// list := ext.ShoppingList{WeekStart: weekStart, Items: items}
|
||||
// if err := row.Scan(&model.ID, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
|
||||
// }
|
||||
// list.ID = model.ID.UUID()
|
||||
// list.CreatedAt = model.CreatedAt.Time()
|
||||
// list.UpdatedAt = model.UpdatedAt.Time()
|
||||
// return list, nil
|
||||
// }
|
||||
|
||||
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
|
||||
var model generatedmodels.ModelPublicRecipes
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
|
||||
&model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return recipeFromModel(model, tags), nil
|
||||
}
|
||||
// func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
|
||||
// var model generatedmodels.ModelPublicRecipes
|
||||
// var tags []string
|
||||
// if err := rows.Scan(&model.ID, &model.Name, &model.Cuisine, &model.PrepTimeMinutes, &model.CookTimeMinutes, &model.Servings,
|
||||
// &model.Ingredients, &model.Instructions, &tags, &model.Rating, &model.Notes, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
// return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
|
||||
// }
|
||||
// if tags == nil {
|
||||
// tags = []string{}
|
||||
// }
|
||||
// return recipeFromModel(model, tags), nil
|
||||
// }
|
||||
|
||||
@@ -81,343 +81,393 @@ func storedFileFromModel(m generatedmodels.ModelPublicStoredFiles) ext.StoredFil
|
||||
}
|
||||
}
|
||||
|
||||
func maintenanceTaskFromModel(m generatedmodels.ModelPublicMaintenanceTasks) ext.MaintenanceTask {
|
||||
var frequencyDays *int
|
||||
if m.FrequencyDays.Valid {
|
||||
n := int(m.FrequencyDays.Int64())
|
||||
frequencyDays = &n
|
||||
// func maintenanceTaskFromModel(m generatedmodels.ModelPublicMaintenanceTasks) ext.MaintenanceTask {
|
||||
// var frequencyDays *int
|
||||
// if m.FrequencyDays.Valid {
|
||||
// n := int(m.FrequencyDays.Int64())
|
||||
// frequencyDays = &n
|
||||
// }
|
||||
|
||||
// var lastCompleted *time.Time
|
||||
// if m.LastCompleted.Valid {
|
||||
// t := m.LastCompleted.Time()
|
||||
// lastCompleted = &t
|
||||
// }
|
||||
|
||||
// var nextDue *time.Time
|
||||
// if m.NextDue.Valid {
|
||||
// t := m.NextDue.Time()
|
||||
// nextDue = &t
|
||||
// }
|
||||
|
||||
// return ext.MaintenanceTask{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// Category: m.Category.String(),
|
||||
// FrequencyDays: frequencyDays,
|
||||
// LastCompleted: lastCompleted,
|
||||
// NextDue: nextDue,
|
||||
// Priority: m.Priority.String(),
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func maintenanceLogFromModel(m generatedmodels.ModelPublicMaintenanceLogs) ext.MaintenanceLog {
|
||||
// var cost *float64
|
||||
// if m.Cost.Valid {
|
||||
// v := m.Cost.Float64()
|
||||
// cost = &v
|
||||
// }
|
||||
|
||||
// return ext.MaintenanceLog{
|
||||
// ID: m.ID.UUID(),
|
||||
// TaskID: m.TaskID.UUID(),
|
||||
// CompletedAt: m.CompletedAt.Time(),
|
||||
// PerformedBy: m.PerformedBy.String(),
|
||||
// Cost: cost,
|
||||
// Notes: m.Notes.String(),
|
||||
// NextAction: m.NextAction.String(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func householdItemFromModel(m generatedmodels.ModelPublicHouseholdItems) ext.HouseholdItem {
|
||||
// details := map[string]any{}
|
||||
// if len(m.Details) > 0 {
|
||||
// if err := json.Unmarshal(m.Details, &details); err != nil {
|
||||
// details = map[string]any{}
|
||||
// }
|
||||
// }
|
||||
|
||||
// return ext.HouseholdItem{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// Category: m.Category.String(),
|
||||
// Location: m.Location.String(),
|
||||
// Details: details,
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func householdVendorFromModel(m generatedmodels.ModelPublicHouseholdVendors) ext.HouseholdVendor {
|
||||
// var rating *int
|
||||
// if m.Rating.Valid {
|
||||
// v := int(m.Rating.Int64())
|
||||
// rating = &v
|
||||
// }
|
||||
|
||||
// var lastUsed *time.Time
|
||||
// if m.LastUsed.Valid {
|
||||
// t := m.LastUsed.Time()
|
||||
// lastUsed = &t
|
||||
// }
|
||||
|
||||
// return ext.HouseholdVendor{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// ServiceType: m.ServiceType.String(),
|
||||
// Phone: m.Phone.String(),
|
||||
// Email: m.Email.String(),
|
||||
// Website: m.Website.String(),
|
||||
// Notes: m.Notes.String(),
|
||||
// Rating: rating,
|
||||
// LastUsed: lastUsed,
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func familyMemberFromModel(m generatedmodels.ModelPublicFamilyMembers) ext.FamilyMember {
|
||||
// var birthDate *time.Time
|
||||
// if m.BirthDate.Valid {
|
||||
// t := m.BirthDate.Time()
|
||||
// birthDate = &t
|
||||
// }
|
||||
|
||||
// return ext.FamilyMember{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// Relationship: m.Relationship.String(),
|
||||
// BirthDate: birthDate,
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func activityFromModel(m generatedmodels.ModelPublicActivities, memberName string) ext.Activity {
|
||||
// var familyMemberID *uuid.UUID
|
||||
// if m.FamilyMemberID.Valid {
|
||||
// id := m.FamilyMemberID.UUID()
|
||||
// familyMemberID = &id
|
||||
// }
|
||||
|
||||
// var startDate *time.Time
|
||||
// if m.StartDate.Valid {
|
||||
// t := m.StartDate.Time()
|
||||
// startDate = &t
|
||||
// }
|
||||
|
||||
// var endDate *time.Time
|
||||
// if m.EndDate.Valid {
|
||||
// t := m.EndDate.Time()
|
||||
// endDate = &t
|
||||
// }
|
||||
|
||||
// return ext.Activity{
|
||||
// ID: m.ID.UUID(),
|
||||
// FamilyMemberID: familyMemberID,
|
||||
// MemberName: memberName,
|
||||
// Title: m.Title.String(),
|
||||
// ActivityType: m.ActivityType.String(),
|
||||
// DayOfWeek: m.DayOfWeek.String(),
|
||||
// StartTime: m.StartTime.String(),
|
||||
// EndTime: m.EndTime.String(),
|
||||
// StartDate: startDate,
|
||||
// EndDate: endDate,
|
||||
// Location: m.Location.String(),
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func importantDateFromModel(m generatedmodels.ModelPublicImportantDates, memberName string) ext.ImportantDate {
|
||||
// var familyMemberID *uuid.UUID
|
||||
// if m.FamilyMemberID.Valid {
|
||||
// id := m.FamilyMemberID.UUID()
|
||||
// familyMemberID = &id
|
||||
// }
|
||||
|
||||
// return ext.ImportantDate{
|
||||
// ID: m.ID.UUID(),
|
||||
// FamilyMemberID: familyMemberID,
|
||||
// MemberName: memberName,
|
||||
// Title: m.Title.String(),
|
||||
// DateValue: m.DateValue.Time(),
|
||||
// RecurringYearly: m.RecurringYearly,
|
||||
// ReminderDaysBefore: int(m.ReminderDaysBefore),
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func professionalContactFromModel(m generatedmodels.ModelPublicProfessionalContacts, tags []string) ext.ProfessionalContact {
|
||||
// var lastContacted *time.Time
|
||||
// if m.LastContacted.Valid {
|
||||
// t := m.LastContacted.Time()
|
||||
// lastContacted = &t
|
||||
// }
|
||||
|
||||
// var followUpDate *time.Time
|
||||
// if m.FollowUpDate.Valid {
|
||||
// t := m.FollowUpDate.Time()
|
||||
// followUpDate = &t
|
||||
// }
|
||||
|
||||
// return ext.ProfessionalContact{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// Company: m.Company.String(),
|
||||
// Title: m.Title.String(),
|
||||
// Email: m.Email.String(),
|
||||
// Phone: m.Phone.String(),
|
||||
// LinkedInURL: m.LinkedinURL.String(),
|
||||
// HowWeMet: m.HowWeMet.String(),
|
||||
// Tags: tags,
|
||||
// Notes: m.Notes.String(),
|
||||
// LastContacted: lastContacted,
|
||||
// FollowUpDate: followUpDate,
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func contactInteractionFromModel(m generatedmodels.ModelPublicContactInteractions) ext.ContactInteraction {
|
||||
// return ext.ContactInteraction{
|
||||
// ID: m.ID.UUID(),
|
||||
// ContactID: m.ContactID.UUID(),
|
||||
// InteractionType: m.InteractionType.String(),
|
||||
// OccurredAt: m.OccurredAt.Time(),
|
||||
// Summary: m.Summary.String(),
|
||||
// FollowUpNeeded: m.FollowUpNeeded,
|
||||
// FollowUpNotes: m.FollowUpNotes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func opportunityFromModel(m generatedmodels.ModelPublicOpportunities) ext.Opportunity {
|
||||
// var contactID *uuid.UUID
|
||||
// if m.ContactID.Valid {
|
||||
// id := m.ContactID.UUID()
|
||||
// contactID = &id
|
||||
// }
|
||||
|
||||
// var value *float64
|
||||
// if m.Value.Valid {
|
||||
// v := m.Value.Float64()
|
||||
// value = &v
|
||||
// }
|
||||
|
||||
// var expectedCloseDate *time.Time
|
||||
// if m.ExpectedCloseDate.Valid {
|
||||
// t := m.ExpectedCloseDate.Time()
|
||||
// expectedCloseDate = &t
|
||||
// }
|
||||
|
||||
// return ext.Opportunity{
|
||||
// ID: m.ID.UUID(),
|
||||
// ContactID: contactID,
|
||||
// Title: m.Title.String(),
|
||||
// Description: m.Description.String(),
|
||||
// Stage: m.Stage.String(),
|
||||
// Value: value,
|
||||
// ExpectedCloseDate: expectedCloseDate,
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func recipeFromModel(m generatedmodels.ModelPublicRecipes, tags []string) ext.Recipe {
|
||||
// var prepTimeMinutes *int
|
||||
// if m.PrepTimeMinutes.Valid {
|
||||
// v := int(m.PrepTimeMinutes.Int64())
|
||||
// prepTimeMinutes = &v
|
||||
// }
|
||||
|
||||
// var cookTimeMinutes *int
|
||||
// if m.CookTimeMinutes.Valid {
|
||||
// v := int(m.CookTimeMinutes.Int64())
|
||||
// cookTimeMinutes = &v
|
||||
// }
|
||||
|
||||
// var servings *int
|
||||
// if m.Servings.Valid {
|
||||
// v := int(m.Servings.Int64())
|
||||
// servings = &v
|
||||
// }
|
||||
|
||||
// var rating *int
|
||||
// if m.Rating.Valid {
|
||||
// v := int(m.Rating.Int64())
|
||||
// rating = &v
|
||||
// }
|
||||
|
||||
// recipe := ext.Recipe{
|
||||
// ID: m.ID.UUID(),
|
||||
// Name: m.Name.String(),
|
||||
// Cuisine: m.Cuisine.String(),
|
||||
// PrepTimeMinutes: prepTimeMinutes,
|
||||
// CookTimeMinutes: cookTimeMinutes,
|
||||
// Servings: servings,
|
||||
// Tags: tags,
|
||||
// Rating: rating,
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
|
||||
// if err := json.Unmarshal(m.Ingredients, &recipe.Ingredients); err != nil {
|
||||
// recipe.Ingredients = []ext.Ingredient{}
|
||||
// }
|
||||
// if err := json.Unmarshal(m.Instructions, &recipe.Instructions); err != nil {
|
||||
// recipe.Instructions = []string{}
|
||||
// }
|
||||
// return recipe
|
||||
// }
|
||||
|
||||
// func mealPlanEntryFromModel(m generatedmodels.ModelPublicMealPlans, recipeName string) ext.MealPlanEntry {
|
||||
// var recipeID *uuid.UUID
|
||||
// if m.RecipeID.Valid {
|
||||
// id := m.RecipeID.UUID()
|
||||
// recipeID = &id
|
||||
// }
|
||||
|
||||
// var servings *int
|
||||
// if m.Servings.Valid {
|
||||
// v := int(m.Servings.Int64())
|
||||
// servings = &v
|
||||
// }
|
||||
|
||||
// return ext.MealPlanEntry{
|
||||
// ID: m.ID.UUID(),
|
||||
// WeekStart: m.WeekStart.Time(),
|
||||
// DayOfWeek: m.DayOfWeek.String(),
|
||||
// MealType: m.MealType.String(),
|
||||
// RecipeID: recipeID,
|
||||
// RecipeName: recipeName,
|
||||
// CustomMeal: m.CustomMeal.String(),
|
||||
// Servings: servings,
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func shoppingListFromModel(m generatedmodels.ModelPublicShoppingLists) ext.ShoppingList {
|
||||
// list := ext.ShoppingList{
|
||||
// ID: m.ID.UUID(),
|
||||
// WeekStart: m.WeekStart.Time(),
|
||||
// Notes: m.Notes.String(),
|
||||
// CreatedAt: m.CreatedAt.Time(),
|
||||
// UpdatedAt: m.UpdatedAt.Time(),
|
||||
// }
|
||||
// if err := json.Unmarshal(m.Items, &list.Items); err != nil {
|
||||
// list.Items = []ext.ShoppingItem{}
|
||||
// }
|
||||
// return list
|
||||
// }
|
||||
|
||||
func planFromModel(m generatedmodels.ModelPublicPlans, tags []string) ext.Plan {
|
||||
var projectID *uuid.UUID
|
||||
if m.ProjectID.Valid {
|
||||
id := m.ProjectID.UUID()
|
||||
projectID = &id
|
||||
}
|
||||
|
||||
var lastCompleted *time.Time
|
||||
if m.LastCompleted.Valid {
|
||||
t := m.LastCompleted.Time()
|
||||
lastCompleted = &t
|
||||
var dueDate *time.Time
|
||||
if m.DueDate.Valid {
|
||||
t := m.DueDate.Time()
|
||||
dueDate = &t
|
||||
}
|
||||
|
||||
var nextDue *time.Time
|
||||
if m.NextDue.Valid {
|
||||
t := m.NextDue.Time()
|
||||
nextDue = &t
|
||||
var completedAt *time.Time
|
||||
if m.CompletedAt.Valid {
|
||||
t := m.CompletedAt.Time()
|
||||
completedAt = &t
|
||||
}
|
||||
|
||||
return ext.MaintenanceTask{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
Category: m.Category.String(),
|
||||
FrequencyDays: frequencyDays,
|
||||
LastCompleted: lastCompleted,
|
||||
NextDue: nextDue,
|
||||
Priority: m.Priority.String(),
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func maintenanceLogFromModel(m generatedmodels.ModelPublicMaintenanceLogs) ext.MaintenanceLog {
|
||||
var cost *float64
|
||||
if m.Cost.Valid {
|
||||
v := m.Cost.Float64()
|
||||
cost = &v
|
||||
var lastReviewedAt *time.Time
|
||||
if m.LastReviewedAt.Valid {
|
||||
t := m.LastReviewedAt.Time()
|
||||
lastReviewedAt = &t
|
||||
}
|
||||
|
||||
return ext.MaintenanceLog{
|
||||
ID: m.ID.UUID(),
|
||||
TaskID: m.TaskID.UUID(),
|
||||
CompletedAt: m.CompletedAt.Time(),
|
||||
PerformedBy: m.PerformedBy.String(),
|
||||
Cost: cost,
|
||||
Notes: m.Notes.String(),
|
||||
NextAction: m.NextAction.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func householdItemFromModel(m generatedmodels.ModelPublicHouseholdItems) ext.HouseholdItem {
|
||||
details := map[string]any{}
|
||||
if len(m.Details) > 0 {
|
||||
if err := json.Unmarshal(m.Details, &details); err != nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
var supersedesPlanID *uuid.UUID
|
||||
if m.SupersedesPlanID.Valid {
|
||||
id := m.SupersedesPlanID.UUID()
|
||||
supersedesPlanID = &id
|
||||
}
|
||||
|
||||
return ext.HouseholdItem{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
Category: m.Category.String(),
|
||||
Location: m.Location.String(),
|
||||
Details: details,
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
return ext.Plan{
|
||||
ID: m.ID.UUID(),
|
||||
Title: m.Title.String(),
|
||||
Description: m.Description.String(),
|
||||
Status: ext.PlanStatus(m.Status.String()),
|
||||
Priority: ext.PlanPriority(m.Priority.String()),
|
||||
ProjectID: projectID,
|
||||
Owner: m.Owner.String(),
|
||||
DueDate: dueDate,
|
||||
CompletedAt: completedAt,
|
||||
ReviewedBy: m.ReviewedBy.String(),
|
||||
LastReviewedAt: lastReviewedAt,
|
||||
SupersedesPlanID: supersedesPlanID,
|
||||
Tags: tags,
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func householdVendorFromModel(m generatedmodels.ModelPublicHouseholdVendors) ext.HouseholdVendor {
|
||||
var rating *int
|
||||
if m.Rating.Valid {
|
||||
v := int(m.Rating.Int64())
|
||||
rating = &v
|
||||
}
|
||||
|
||||
var lastUsed *time.Time
|
||||
if m.LastUsed.Valid {
|
||||
t := m.LastUsed.Time()
|
||||
lastUsed = &t
|
||||
}
|
||||
|
||||
return ext.HouseholdVendor{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
ServiceType: m.ServiceType.String(),
|
||||
Phone: m.Phone.String(),
|
||||
Email: m.Email.String(),
|
||||
Website: m.Website.String(),
|
||||
Notes: m.Notes.String(),
|
||||
Rating: rating,
|
||||
LastUsed: lastUsed,
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func familyMemberFromModel(m generatedmodels.ModelPublicFamilyMembers) ext.FamilyMember {
|
||||
var birthDate *time.Time
|
||||
if m.BirthDate.Valid {
|
||||
t := m.BirthDate.Time()
|
||||
birthDate = &t
|
||||
}
|
||||
|
||||
return ext.FamilyMember{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
Relationship: m.Relationship.String(),
|
||||
BirthDate: birthDate,
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func activityFromModel(m generatedmodels.ModelPublicActivities, memberName string) ext.Activity {
|
||||
var familyMemberID *uuid.UUID
|
||||
if m.FamilyMemberID.Valid {
|
||||
id := m.FamilyMemberID.UUID()
|
||||
familyMemberID = &id
|
||||
}
|
||||
|
||||
var startDate *time.Time
|
||||
if m.StartDate.Valid {
|
||||
t := m.StartDate.Time()
|
||||
startDate = &t
|
||||
}
|
||||
|
||||
var endDate *time.Time
|
||||
if m.EndDate.Valid {
|
||||
t := m.EndDate.Time()
|
||||
endDate = &t
|
||||
}
|
||||
|
||||
return ext.Activity{
|
||||
ID: m.ID.UUID(),
|
||||
FamilyMemberID: familyMemberID,
|
||||
MemberName: memberName,
|
||||
Title: m.Title.String(),
|
||||
ActivityType: m.ActivityType.String(),
|
||||
DayOfWeek: m.DayOfWeek.String(),
|
||||
StartTime: m.StartTime.String(),
|
||||
EndTime: m.EndTime.String(),
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
Location: m.Location.String(),
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func importantDateFromModel(m generatedmodels.ModelPublicImportantDates, memberName string) ext.ImportantDate {
|
||||
var familyMemberID *uuid.UUID
|
||||
if m.FamilyMemberID.Valid {
|
||||
id := m.FamilyMemberID.UUID()
|
||||
familyMemberID = &id
|
||||
}
|
||||
|
||||
return ext.ImportantDate{
|
||||
ID: m.ID.UUID(),
|
||||
FamilyMemberID: familyMemberID,
|
||||
MemberName: memberName,
|
||||
Title: m.Title.String(),
|
||||
DateValue: m.DateValue.Time(),
|
||||
RecurringYearly: m.RecurringYearly,
|
||||
ReminderDaysBefore: int(m.ReminderDaysBefore.Int64()),
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func professionalContactFromModel(m generatedmodels.ModelPublicProfessionalContacts, tags []string) ext.ProfessionalContact {
|
||||
var lastContacted *time.Time
|
||||
if m.LastContacted.Valid {
|
||||
t := m.LastContacted.Time()
|
||||
lastContacted = &t
|
||||
}
|
||||
|
||||
var followUpDate *time.Time
|
||||
if m.FollowUpDate.Valid {
|
||||
t := m.FollowUpDate.Time()
|
||||
followUpDate = &t
|
||||
}
|
||||
|
||||
return ext.ProfessionalContact{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
Company: m.Company.String(),
|
||||
Title: m.Title.String(),
|
||||
Email: m.Email.String(),
|
||||
Phone: m.Phone.String(),
|
||||
LinkedInURL: m.LinkedinURL.String(),
|
||||
HowWeMet: m.HowWeMet.String(),
|
||||
Tags: tags,
|
||||
Notes: m.Notes.String(),
|
||||
LastContacted: lastContacted,
|
||||
FollowUpDate: followUpDate,
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func contactInteractionFromModel(m generatedmodels.ModelPublicContactInteractions) ext.ContactInteraction {
|
||||
return ext.ContactInteraction{
|
||||
ID: m.ID.UUID(),
|
||||
ContactID: m.ContactID.UUID(),
|
||||
InteractionType: m.InteractionType.String(),
|
||||
OccurredAt: m.OccurredAt.Time(),
|
||||
Summary: m.Summary.String(),
|
||||
FollowUpNeeded: m.FollowUpNeeded,
|
||||
FollowUpNotes: m.FollowUpNotes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func opportunityFromModel(m generatedmodels.ModelPublicOpportunities) ext.Opportunity {
|
||||
var contactID *uuid.UUID
|
||||
if m.ContactID.Valid {
|
||||
id := m.ContactID.UUID()
|
||||
contactID = &id
|
||||
}
|
||||
|
||||
var value *float64
|
||||
if m.Value.Valid {
|
||||
v := m.Value.Float64()
|
||||
value = &v
|
||||
}
|
||||
|
||||
var expectedCloseDate *time.Time
|
||||
if m.ExpectedCloseDate.Valid {
|
||||
t := m.ExpectedCloseDate.Time()
|
||||
expectedCloseDate = &t
|
||||
}
|
||||
|
||||
return ext.Opportunity{
|
||||
ID: m.ID.UUID(),
|
||||
ContactID: contactID,
|
||||
Title: m.Title.String(),
|
||||
Description: m.Description.String(),
|
||||
Stage: m.Stage.String(),
|
||||
Value: value,
|
||||
ExpectedCloseDate: expectedCloseDate,
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func recipeFromModel(m generatedmodels.ModelPublicRecipes, tags []string) ext.Recipe {
|
||||
var prepTimeMinutes *int
|
||||
if m.PrepTimeMinutes.Valid {
|
||||
v := int(m.PrepTimeMinutes.Int64())
|
||||
prepTimeMinutes = &v
|
||||
}
|
||||
|
||||
var cookTimeMinutes *int
|
||||
if m.CookTimeMinutes.Valid {
|
||||
v := int(m.CookTimeMinutes.Int64())
|
||||
cookTimeMinutes = &v
|
||||
}
|
||||
|
||||
var servings *int
|
||||
if m.Servings.Valid {
|
||||
v := int(m.Servings.Int64())
|
||||
servings = &v
|
||||
}
|
||||
|
||||
var rating *int
|
||||
if m.Rating.Valid {
|
||||
v := int(m.Rating.Int64())
|
||||
rating = &v
|
||||
}
|
||||
|
||||
recipe := ext.Recipe{
|
||||
ID: m.ID.UUID(),
|
||||
Name: m.Name.String(),
|
||||
Cuisine: m.Cuisine.String(),
|
||||
PrepTimeMinutes: prepTimeMinutes,
|
||||
CookTimeMinutes: cookTimeMinutes,
|
||||
Servings: servings,
|
||||
Tags: tags,
|
||||
Rating: rating,
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(m.Ingredients, &recipe.Ingredients); err != nil {
|
||||
recipe.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if err := json.Unmarshal(m.Instructions, &recipe.Instructions); err != nil {
|
||||
recipe.Instructions = []string{}
|
||||
}
|
||||
return recipe
|
||||
}
|
||||
|
||||
func mealPlanEntryFromModel(m generatedmodels.ModelPublicMealPlans, recipeName string) ext.MealPlanEntry {
|
||||
var recipeID *uuid.UUID
|
||||
if m.RecipeID.Valid {
|
||||
id := m.RecipeID.UUID()
|
||||
recipeID = &id
|
||||
}
|
||||
|
||||
var servings *int
|
||||
if m.Servings.Valid {
|
||||
v := int(m.Servings.Int64())
|
||||
servings = &v
|
||||
}
|
||||
|
||||
return ext.MealPlanEntry{
|
||||
ID: m.ID.UUID(),
|
||||
WeekStart: m.WeekStart.Time(),
|
||||
DayOfWeek: m.DayOfWeek.String(),
|
||||
MealType: m.MealType.String(),
|
||||
RecipeID: recipeID,
|
||||
RecipeName: recipeName,
|
||||
CustomMeal: m.CustomMeal.String(),
|
||||
Servings: servings,
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
}
|
||||
}
|
||||
|
||||
func shoppingListFromModel(m generatedmodels.ModelPublicShoppingLists) ext.ShoppingList {
|
||||
list := ext.ShoppingList{
|
||||
ID: m.ID.UUID(),
|
||||
WeekStart: m.WeekStart.Time(),
|
||||
Notes: m.Notes.String(),
|
||||
CreatedAt: m.CreatedAt.Time(),
|
||||
UpdatedAt: m.UpdatedAt.Time(),
|
||||
}
|
||||
if err := json.Unmarshal(m.Items, &list.Items); err != nil {
|
||||
list.Items = []ext.ShoppingItem{}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func learningFromModel(m generatedmodels.ModelPublicLearnings, tags []string) ext.Learning {
|
||||
var projectID *uuid.UUID
|
||||
if m.ProjectID.Valid {
|
||||
|
||||
477
internal/store/plans.go
Normal file
477
internal/store/plans.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
const planColumns = `
|
||||
id, title, description, status, priority, project_id, owner, due_date,
|
||||
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags::text[], created_at, updated_at`
|
||||
|
||||
func (db *DB) CreatePlan(ctx context.Context, plan ext.Plan) (ext.Plan, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into plans (title, description, status, priority, project_id, owner, due_date,
|
||||
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
returning`+planColumns,
|
||||
strings.TrimSpace(plan.Title),
|
||||
strings.TrimSpace(plan.Description),
|
||||
string(plan.Status),
|
||||
string(plan.Priority),
|
||||
plan.ProjectID,
|
||||
nullableText(plan.Owner),
|
||||
plan.DueDate,
|
||||
plan.CompletedAt,
|
||||
nullableText(plan.ReviewedBy),
|
||||
plan.LastReviewedAt,
|
||||
plan.SupersedesPlanID,
|
||||
plan.Tags,
|
||||
)
|
||||
return scanPlan(row)
|
||||
}
|
||||
|
||||
func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (ext.Plan, error) {
|
||||
row := db.pool.QueryRow(ctx, `select`+planColumns+` from plans where id = $1`, id)
|
||||
plan, err := scanPlan(row)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
|
||||
}
|
||||
return ext.Plan{}, fmt.Errorf("get plan: %w", err)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetPlanDetail(ctx context.Context, id uuid.UUID) (ext.PlanDetail, error) {
|
||||
plan, err := db.GetPlan(ctx, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, err
|
||||
}
|
||||
|
||||
dependsOn, err := db.listPlansByQuery(ctx, `
|
||||
select`+planColumns+`
|
||||
from plans p
|
||||
join plan_dependencies pd on pd.depends_on_plan_id = p.id
|
||||
where pd.plan_id = $1 order by p.title`, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, fmt.Errorf("get plan depends_on: %w", err)
|
||||
}
|
||||
|
||||
blocks, err := db.listPlansByQuery(ctx, `
|
||||
select`+planColumns+`
|
||||
from plans p
|
||||
join plan_dependencies pd on pd.plan_id = p.id
|
||||
where pd.depends_on_plan_id = $1 order by p.title`, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, fmt.Errorf("get plan blocks: %w", err)
|
||||
}
|
||||
|
||||
related, err := db.listPlansByQuery(ctx, `
|
||||
select`+planColumns+`
|
||||
from plans p
|
||||
where p.id in (
|
||||
select plan_b_id from plan_related_plans where plan_a_id = $1
|
||||
union
|
||||
select plan_a_id from plan_related_plans where plan_b_id = $1
|
||||
) order by p.title`, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, fmt.Errorf("get plan related: %w", err)
|
||||
}
|
||||
|
||||
skills, err := db.ListPlanSkills(ctx, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, fmt.Errorf("get plan skills: %w", err)
|
||||
}
|
||||
|
||||
guardrails, err := db.ListPlanGuardrails(ctx, id)
|
||||
if err != nil {
|
||||
return ext.PlanDetail{}, fmt.Errorf("get plan guardrails: %w", err)
|
||||
}
|
||||
|
||||
return ext.PlanDetail{
|
||||
Plan: plan,
|
||||
DependsOn: dependsOn,
|
||||
Blocks: blocks,
|
||||
RelatedPlans: related,
|
||||
Skills: skills,
|
||||
Guardrails: guardrails,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdatePlan(ctx context.Context, id uuid.UUID, u ext.PlanUpdate) (ext.Plan, error) {
|
||||
sets := []string{"updated_at = now()"}
|
||||
args := []any{}
|
||||
|
||||
if u.Title != nil {
|
||||
args = append(args, strings.TrimSpace(*u.Title))
|
||||
sets = append(sets, fmt.Sprintf("title = $%d", len(args)))
|
||||
}
|
||||
if u.Description != nil {
|
||||
args = append(args, strings.TrimSpace(*u.Description))
|
||||
sets = append(sets, fmt.Sprintf("description = $%d", len(args)))
|
||||
}
|
||||
if u.Status != nil {
|
||||
args = append(args, strings.TrimSpace(*u.Status))
|
||||
sets = append(sets, fmt.Sprintf("status = $%d", len(args)))
|
||||
}
|
||||
if u.Priority != nil {
|
||||
args = append(args, strings.TrimSpace(*u.Priority))
|
||||
sets = append(sets, fmt.Sprintf("priority = $%d", len(args)))
|
||||
}
|
||||
if u.Owner != nil {
|
||||
args = append(args, nullableText(*u.Owner))
|
||||
sets = append(sets, fmt.Sprintf("owner = $%d", len(args)))
|
||||
}
|
||||
if u.ClearDueDate {
|
||||
sets = append(sets, "due_date = null")
|
||||
} else if u.DueDate != nil {
|
||||
args = append(args, *u.DueDate)
|
||||
sets = append(sets, fmt.Sprintf("due_date = $%d", len(args)))
|
||||
}
|
||||
if u.ClearCompletedAt {
|
||||
sets = append(sets, "completed_at = null")
|
||||
} else if u.CompletedAt != nil {
|
||||
args = append(args, *u.CompletedAt)
|
||||
sets = append(sets, fmt.Sprintf("completed_at = $%d", len(args)))
|
||||
}
|
||||
if u.ReviewedBy != nil {
|
||||
args = append(args, nullableText(*u.ReviewedBy))
|
||||
sets = append(sets, fmt.Sprintf("reviewed_by = $%d", len(args)))
|
||||
}
|
||||
if u.MarkReviewed {
|
||||
sets = append(sets, "last_reviewed_at = now()")
|
||||
}
|
||||
if u.ClearSupersedesPlanID {
|
||||
sets = append(sets, "supersedes_plan_id = null")
|
||||
} else if u.SupersedesPlanID != nil {
|
||||
args = append(args, *u.SupersedesPlanID)
|
||||
sets = append(sets, fmt.Sprintf("supersedes_plan_id = $%d", len(args)))
|
||||
}
|
||||
if u.Tags != nil {
|
||||
args = append(args, *u.Tags)
|
||||
sets = append(sets, fmt.Sprintf("tags = $%d", len(args)))
|
||||
}
|
||||
|
||||
args = append(args, id)
|
||||
query := fmt.Sprintf(
|
||||
"update plans set %s where id = $%d returning%s",
|
||||
strings.Join(sets, ", "), len(args), planColumns,
|
||||
)
|
||||
|
||||
row := db.pool.QueryRow(ctx, query, args...)
|
||||
plan, err := scanPlan(row)
|
||||
if err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
|
||||
}
|
||||
return ext.Plan{}, fmt.Errorf("update plan: %w", err)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func (db *DB) DeletePlan(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := db.pool.Exec(ctx, `delete from plans where id = $1`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete plan: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("plan not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListPlans(ctx context.Context, filter ext.PlanFilter) ([]ext.Plan, error) {
|
||||
args := make([]any, 0, 8)
|
||||
conditions := make([]string, 0, 8)
|
||||
|
||||
if filter.ProjectID != nil {
|
||||
args = append(args, *filter.ProjectID)
|
||||
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
|
||||
}
|
||||
if v := strings.TrimSpace(filter.Status); v != "" {
|
||||
args = append(args, v)
|
||||
conditions = append(conditions, fmt.Sprintf("status = $%d", len(args)))
|
||||
}
|
||||
if v := strings.TrimSpace(filter.Priority); v != "" {
|
||||
args = append(args, v)
|
||||
conditions = append(conditions, fmt.Sprintf("priority = $%d", len(args)))
|
||||
}
|
||||
if v := strings.TrimSpace(filter.Owner); v != "" {
|
||||
args = append(args, v)
|
||||
conditions = append(conditions, fmt.Sprintf("owner = $%d", len(args)))
|
||||
}
|
||||
if v := strings.TrimSpace(filter.Tag); v != "" {
|
||||
args = append(args, v)
|
||||
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
|
||||
}
|
||||
if v := strings.TrimSpace(filter.Query); v != "" {
|
||||
args = append(args, v)
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"to_tsvector('simple', title || ' ' || coalesce(description, '')) @@ websearch_to_tsquery('simple', $%d)", len(args)))
|
||||
}
|
||||
|
||||
query := "select" + planColumns + " from plans"
|
||||
if len(conditions) > 0 {
|
||||
query += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
query += " order by updated_at desc"
|
||||
if filter.Limit > 0 {
|
||||
args = append(args, filter.Limit)
|
||||
query += fmt.Sprintf(" limit $%d", len(args))
|
||||
}
|
||||
|
||||
return db.listPlansByQuery(ctx, query, args...)
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
|
||||
func (db *DB) AddPlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into plan_dependencies (plan_id, depends_on_plan_id)
|
||||
values ($1, $2)
|
||||
on conflict do nothing
|
||||
`, planID, dependsOnPlanID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add plan dependency: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RemovePlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
|
||||
tag, err := db.pool.Exec(ctx, `
|
||||
delete from plan_dependencies where plan_id = $1 and depends_on_plan_id = $2
|
||||
`, planID, dependsOnPlanID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove plan dependency: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("plan dependency not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Related Plans
|
||||
|
||||
func (db *DB) AddRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
|
||||
a, b := canonicalPlanPair(planAID, planBID)
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into plan_related_plans (plan_a_id, plan_b_id)
|
||||
values ($1, $2)
|
||||
on conflict do nothing
|
||||
`, a, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add related plan: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RemoveRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
|
||||
a, b := canonicalPlanPair(planAID, planBID)
|
||||
tag, err := db.pool.Exec(ctx, `
|
||||
delete from plan_related_plans where plan_a_id = $1 and plan_b_id = $2
|
||||
`, a, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove related plan: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("related plan link not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Plan Skills
|
||||
|
||||
func (db *DB) AddPlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into plan_skills (plan_id, skill_id) values ($1, $2) on conflict do nothing
|
||||
`, planID, skillID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add plan skill: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RemovePlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
|
||||
tag, err := db.pool.Exec(ctx, `
|
||||
delete from plan_skills where plan_id = $1 and skill_id = $2
|
||||
`, planID, skillID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove plan skill: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("plan skill link not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListPlanSkills(ctx context.Context, planID uuid.UUID) ([]ext.AgentSkill, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select s.id, s.name, s.description, s.content, s.tags::text[], s.created_at, s.updated_at
|
||||
from agent_skills s
|
||||
join plan_skills ps on ps.skill_id = s.id
|
||||
where ps.plan_id = $1
|
||||
order by s.name
|
||||
`, planID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list plan skills: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var skills []ext.AgentSkill
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicAgentSkills
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan plan skill: %w", err)
|
||||
}
|
||||
s := ext.AgentSkill{
|
||||
ID: model.ID.UUID(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Tags: tags,
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}
|
||||
if s.Tags == nil {
|
||||
s.Tags = []string{}
|
||||
}
|
||||
skills = append(skills, s)
|
||||
}
|
||||
return skills, rows.Err()
|
||||
}
|
||||
|
||||
// Plan Guardrails
|
||||
|
||||
func (db *DB) AddPlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into plan_guardrails (plan_id, guardrail_id) values ($1, $2) on conflict do nothing
|
||||
`, planID, guardrailID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add plan guardrail: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) RemovePlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
|
||||
tag, err := db.pool.Exec(ctx, `
|
||||
delete from plan_guardrails where plan_id = $1 and guardrail_id = $2
|
||||
`, planID, guardrailID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove plan guardrail: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("plan guardrail link not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListPlanGuardrails(ctx context.Context, planID uuid.UUID) ([]ext.AgentGuardrail, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select g.id, g.name, g.description, g.content, g.severity, g.tags::text[], g.created_at, g.updated_at
|
||||
from agent_guardrails g
|
||||
join plan_guardrails pg on pg.guardrail_id = g.id
|
||||
where pg.plan_id = $1
|
||||
order by g.name
|
||||
`, planID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list plan guardrails: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var guardrails []ext.AgentGuardrail
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicAgentGuardrails
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &model.Severity, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan plan guardrail: %w", err)
|
||||
}
|
||||
g := ext.AgentGuardrail{
|
||||
ID: model.ID.UUID(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Severity: model.Severity.String(),
|
||||
Tags: tags,
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}
|
||||
if g.Tags == nil {
|
||||
g.Tags = []string{}
|
||||
}
|
||||
guardrails = append(guardrails, g)
|
||||
}
|
||||
return guardrails, rows.Err()
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
type planScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPlan(row planScanner) (ext.Plan, error) {
|
||||
var model generatedmodels.ModelPublicPlans
|
||||
var tags []string
|
||||
err := row.Scan(
|
||||
&model.ID,
|
||||
&model.Title,
|
||||
&model.Description,
|
||||
&model.Status,
|
||||
&model.Priority,
|
||||
&model.ProjectID,
|
||||
&model.Owner,
|
||||
&model.DueDate,
|
||||
&model.CompletedAt,
|
||||
&model.ReviewedBy,
|
||||
&model.LastReviewedAt,
|
||||
&model.SupersedesPlanID,
|
||||
&tags,
|
||||
&model.CreatedAt,
|
||||
&model.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return ext.Plan{}, err
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return planFromModel(model, tags), nil
|
||||
}
|
||||
|
||||
func (db *DB) listPlansByQuery(ctx context.Context, query string, args ...any) ([]ext.Plan, error) {
|
||||
rows, err := db.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
plans := make([]ext.Plan, 0)
|
||||
for rows.Next() {
|
||||
plan, err := scanPlan(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan plan: %w", err)
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate plans: %w", err)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// canonicalPlanPair ensures the smaller UUID is always plan_a_id to prevent duplicates.
|
||||
func canonicalPlanPair(a, b uuid.UUID) (uuid.UUID, uuid.UUID) {
|
||||
if strings.Compare(a.String(), b.String()) <= 0 {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
|
||||
@@ -1,212 +1,212 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/store"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
type CalendarTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
// type CalendarTool struct {
|
||||
// store *store.DB
|
||||
// }
|
||||
|
||||
func NewCalendarTool(db *store.DB) *CalendarTool {
|
||||
return &CalendarTool{store: db}
|
||||
}
|
||||
// func NewCalendarTool(db *store.DB) *CalendarTool {
|
||||
// return &CalendarTool{store: db}
|
||||
// }
|
||||
|
||||
// add_family_member
|
||||
// // add_family_member
|
||||
|
||||
type AddFamilyMemberInput struct {
|
||||
Name string `json:"name" jsonschema:"person's name"`
|
||||
Relationship string `json:"relationship,omitempty" jsonschema:"e.g. self, spouse, child, parent"`
|
||||
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddFamilyMemberInput struct {
|
||||
// Name string `json:"name" jsonschema:"person's name"`
|
||||
// Relationship string `json:"relationship,omitempty" jsonschema:"e.g. self, spouse, child, parent"`
|
||||
// BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddFamilyMemberOutput struct {
|
||||
Member ext.FamilyMember `json:"member"`
|
||||
}
|
||||
// type AddFamilyMemberOutput struct {
|
||||
// Member ext.FamilyMember `json:"member"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddFamilyMemberOutput{}, errRequiredField("name")
|
||||
}
|
||||
member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Relationship: strings.TrimSpace(in.Relationship),
|
||||
BirthDate: in.BirthDate,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddFamilyMemberOutput{}, err
|
||||
}
|
||||
return nil, AddFamilyMemberOutput{Member: member}, nil
|
||||
}
|
||||
// func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddFamilyMemberOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Relationship: strings.TrimSpace(in.Relationship),
|
||||
// BirthDate: in.BirthDate,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddFamilyMemberOutput{}, err
|
||||
// }
|
||||
// return nil, AddFamilyMemberOutput{Member: member}, nil
|
||||
// }
|
||||
|
||||
// list_family_members
|
||||
// // list_family_members
|
||||
|
||||
type ListFamilyMembersInput struct{}
|
||||
// type ListFamilyMembersInput struct{}
|
||||
|
||||
type ListFamilyMembersOutput struct {
|
||||
Members []ext.FamilyMember `json:"members"`
|
||||
}
|
||||
// type ListFamilyMembersOutput struct {
|
||||
// Members []ext.FamilyMember `json:"members"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) ListMembers(ctx context.Context, _ *mcp.CallToolRequest, _ ListFamilyMembersInput) (*mcp.CallToolResult, ListFamilyMembersOutput, error) {
|
||||
members, err := t.store.ListFamilyMembers(ctx)
|
||||
if err != nil {
|
||||
return nil, ListFamilyMembersOutput{}, err
|
||||
}
|
||||
if members == nil {
|
||||
members = []ext.FamilyMember{}
|
||||
}
|
||||
return nil, ListFamilyMembersOutput{Members: members}, nil
|
||||
}
|
||||
// func (t *CalendarTool) ListMembers(ctx context.Context, _ *mcp.CallToolRequest, _ ListFamilyMembersInput) (*mcp.CallToolResult, ListFamilyMembersOutput, error) {
|
||||
// members, err := t.store.ListFamilyMembers(ctx)
|
||||
// if err != nil {
|
||||
// return nil, ListFamilyMembersOutput{}, err
|
||||
// }
|
||||
// if members == nil {
|
||||
// members = []ext.FamilyMember{}
|
||||
// }
|
||||
// return nil, ListFamilyMembersOutput{Members: members}, nil
|
||||
// }
|
||||
|
||||
// add_activity
|
||||
// // add_activity
|
||||
|
||||
type AddActivityInput struct {
|
||||
Title string `json:"title" jsonschema:"activity title"`
|
||||
ActivityType string `json:"activity_type,omitempty" jsonschema:"e.g. sports, medical, school, social"`
|
||||
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"leave empty for whole-family activities"`
|
||||
DayOfWeek string `json:"day_of_week,omitempty" jsonschema:"for recurring: monday, tuesday, etc."`
|
||||
StartTime string `json:"start_time,omitempty" jsonschema:"HH:MM format"`
|
||||
EndTime string `json:"end_time,omitempty" jsonschema:"HH:MM format"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty" jsonschema:"for recurring activities, when they end"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddActivityInput struct {
|
||||
// Title string `json:"title" jsonschema:"activity title"`
|
||||
// ActivityType string `json:"activity_type,omitempty" jsonschema:"e.g. sports, medical, school, social"`
|
||||
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"leave empty for whole-family activities"`
|
||||
// DayOfWeek string `json:"day_of_week,omitempty" jsonschema:"for recurring: monday, tuesday, etc."`
|
||||
// StartTime string `json:"start_time,omitempty" jsonschema:"HH:MM format"`
|
||||
// EndTime string `json:"end_time,omitempty" jsonschema:"HH:MM format"`
|
||||
// StartDate *time.Time `json:"start_date,omitempty"`
|
||||
// EndDate *time.Time `json:"end_date,omitempty" jsonschema:"for recurring activities, when they end"`
|
||||
// Location string `json:"location,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddActivityOutput struct {
|
||||
Activity ext.Activity `json:"activity"`
|
||||
}
|
||||
// type AddActivityOutput struct {
|
||||
// Activity ext.Activity `json:"activity"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, AddActivityOutput{}, errRequiredField("title")
|
||||
}
|
||||
activity, err := t.store.AddActivity(ctx, ext.Activity{
|
||||
FamilyMemberID: in.FamilyMemberID,
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
ActivityType: strings.TrimSpace(in.ActivityType),
|
||||
DayOfWeek: strings.ToLower(strings.TrimSpace(in.DayOfWeek)),
|
||||
StartTime: strings.TrimSpace(in.StartTime),
|
||||
EndTime: strings.TrimSpace(in.EndTime),
|
||||
StartDate: in.StartDate,
|
||||
EndDate: in.EndDate,
|
||||
Location: strings.TrimSpace(in.Location),
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddActivityOutput{}, err
|
||||
}
|
||||
return nil, AddActivityOutput{Activity: activity}, nil
|
||||
}
|
||||
// func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) {
|
||||
// if strings.TrimSpace(in.Title) == "" {
|
||||
// return nil, AddActivityOutput{}, errRequiredField("title")
|
||||
// }
|
||||
// activity, err := t.store.AddActivity(ctx, ext.Activity{
|
||||
// FamilyMemberID: in.FamilyMemberID,
|
||||
// Title: strings.TrimSpace(in.Title),
|
||||
// ActivityType: strings.TrimSpace(in.ActivityType),
|
||||
// DayOfWeek: strings.ToLower(strings.TrimSpace(in.DayOfWeek)),
|
||||
// StartTime: strings.TrimSpace(in.StartTime),
|
||||
// EndTime: strings.TrimSpace(in.EndTime),
|
||||
// StartDate: in.StartDate,
|
||||
// EndDate: in.EndDate,
|
||||
// Location: strings.TrimSpace(in.Location),
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddActivityOutput{}, err
|
||||
// }
|
||||
// return nil, AddActivityOutput{Activity: activity}, nil
|
||||
// }
|
||||
|
||||
// get_week_schedule
|
||||
// // get_week_schedule
|
||||
|
||||
type GetWeekScheduleInput struct {
|
||||
WeekStart time.Time `json:"week_start" jsonschema:"start of the week (Monday) to retrieve"`
|
||||
}
|
||||
// type GetWeekScheduleInput struct {
|
||||
// WeekStart time.Time `json:"week_start" jsonschema:"start of the week (Monday) to retrieve"`
|
||||
// }
|
||||
|
||||
type GetWeekScheduleOutput struct {
|
||||
Activities []ext.Activity `json:"activities"`
|
||||
}
|
||||
// type GetWeekScheduleOutput struct {
|
||||
// Activities []ext.Activity `json:"activities"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) GetWeekSchedule(ctx context.Context, _ *mcp.CallToolRequest, in GetWeekScheduleInput) (*mcp.CallToolResult, GetWeekScheduleOutput, error) {
|
||||
activities, err := t.store.GetWeekSchedule(ctx, in.WeekStart)
|
||||
if err != nil {
|
||||
return nil, GetWeekScheduleOutput{}, err
|
||||
}
|
||||
if activities == nil {
|
||||
activities = []ext.Activity{}
|
||||
}
|
||||
return nil, GetWeekScheduleOutput{Activities: activities}, nil
|
||||
}
|
||||
// func (t *CalendarTool) GetWeekSchedule(ctx context.Context, _ *mcp.CallToolRequest, in GetWeekScheduleInput) (*mcp.CallToolResult, GetWeekScheduleOutput, error) {
|
||||
// activities, err := t.store.GetWeekSchedule(ctx, in.WeekStart)
|
||||
// if err != nil {
|
||||
// return nil, GetWeekScheduleOutput{}, err
|
||||
// }
|
||||
// if activities == nil {
|
||||
// activities = []ext.Activity{}
|
||||
// }
|
||||
// return nil, GetWeekScheduleOutput{Activities: activities}, nil
|
||||
// }
|
||||
|
||||
// search_activities
|
||||
// // search_activities
|
||||
|
||||
type SearchActivitiesInput struct {
|
||||
Query string `json:"query,omitempty" jsonschema:"search text matching title or notes"`
|
||||
ActivityType string `json:"activity_type,omitempty" jsonschema:"filter by type"`
|
||||
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"filter by family member"`
|
||||
}
|
||||
// type SearchActivitiesInput struct {
|
||||
// Query string `json:"query,omitempty" jsonschema:"search text matching title or notes"`
|
||||
// ActivityType string `json:"activity_type,omitempty" jsonschema:"filter by type"`
|
||||
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"filter by family member"`
|
||||
// }
|
||||
|
||||
type SearchActivitiesOutput struct {
|
||||
Activities []ext.Activity `json:"activities"`
|
||||
}
|
||||
// type SearchActivitiesOutput struct {
|
||||
// Activities []ext.Activity `json:"activities"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) SearchActivities(ctx context.Context, _ *mcp.CallToolRequest, in SearchActivitiesInput) (*mcp.CallToolResult, SearchActivitiesOutput, error) {
|
||||
activities, err := t.store.SearchActivities(ctx, in.Query, in.ActivityType, in.FamilyMemberID)
|
||||
if err != nil {
|
||||
return nil, SearchActivitiesOutput{}, err
|
||||
}
|
||||
if activities == nil {
|
||||
activities = []ext.Activity{}
|
||||
}
|
||||
return nil, SearchActivitiesOutput{Activities: activities}, nil
|
||||
}
|
||||
// func (t *CalendarTool) SearchActivities(ctx context.Context, _ *mcp.CallToolRequest, in SearchActivitiesInput) (*mcp.CallToolResult, SearchActivitiesOutput, error) {
|
||||
// activities, err := t.store.SearchActivities(ctx, in.Query, in.ActivityType, in.FamilyMemberID)
|
||||
// if err != nil {
|
||||
// return nil, SearchActivitiesOutput{}, err
|
||||
// }
|
||||
// if activities == nil {
|
||||
// activities = []ext.Activity{}
|
||||
// }
|
||||
// return nil, SearchActivitiesOutput{Activities: activities}, nil
|
||||
// }
|
||||
|
||||
// add_important_date
|
||||
// // add_important_date
|
||||
|
||||
type AddImportantDateInput struct {
|
||||
Title string `json:"title" jsonschema:"description of the date"`
|
||||
DateValue time.Time `json:"date_value" jsonschema:"the date"`
|
||||
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||
RecurringYearly bool `json:"recurring_yearly,omitempty" jsonschema:"if true, reminds every year"`
|
||||
ReminderDaysBefore int `json:"reminder_days_before,omitempty" jsonschema:"how many days before to remind (default: 7)"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddImportantDateInput struct {
|
||||
// Title string `json:"title" jsonschema:"description of the date"`
|
||||
// DateValue time.Time `json:"date_value" jsonschema:"the date"`
|
||||
// FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||
// RecurringYearly bool `json:"recurring_yearly,omitempty" jsonschema:"if true, reminds every year"`
|
||||
// ReminderDaysBefore int `json:"reminder_days_before,omitempty" jsonschema:"how many days before to remind (default: 7)"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddImportantDateOutput struct {
|
||||
Date ext.ImportantDate `json:"date"`
|
||||
}
|
||||
// type AddImportantDateOutput struct {
|
||||
// Date ext.ImportantDate `json:"date"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, AddImportantDateOutput{}, errRequiredField("title")
|
||||
}
|
||||
reminder := in.ReminderDaysBefore
|
||||
if reminder <= 0 {
|
||||
reminder = 7
|
||||
}
|
||||
d, err := t.store.AddImportantDate(ctx, ext.ImportantDate{
|
||||
FamilyMemberID: in.FamilyMemberID,
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
DateValue: in.DateValue,
|
||||
RecurringYearly: in.RecurringYearly,
|
||||
ReminderDaysBefore: reminder,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddImportantDateOutput{}, err
|
||||
}
|
||||
return nil, AddImportantDateOutput{Date: d}, nil
|
||||
}
|
||||
// func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
|
||||
// if strings.TrimSpace(in.Title) == "" {
|
||||
// return nil, AddImportantDateOutput{}, errRequiredField("title")
|
||||
// }
|
||||
// reminder := in.ReminderDaysBefore
|
||||
// if reminder <= 0 {
|
||||
// reminder = 7
|
||||
// }
|
||||
// d, err := t.store.AddImportantDate(ctx, ext.ImportantDate{
|
||||
// FamilyMemberID: in.FamilyMemberID,
|
||||
// Title: strings.TrimSpace(in.Title),
|
||||
// DateValue: in.DateValue,
|
||||
// RecurringYearly: in.RecurringYearly,
|
||||
// ReminderDaysBefore: reminder,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddImportantDateOutput{}, err
|
||||
// }
|
||||
// return nil, AddImportantDateOutput{Date: d}, nil
|
||||
// }
|
||||
|
||||
// get_upcoming_dates
|
||||
// // get_upcoming_dates
|
||||
|
||||
type GetUpcomingDatesInput struct {
|
||||
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||
}
|
||||
// type GetUpcomingDatesInput struct {
|
||||
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||
// }
|
||||
|
||||
type GetUpcomingDatesOutput struct {
|
||||
Dates []ext.ImportantDate `json:"dates"`
|
||||
}
|
||||
// type GetUpcomingDatesOutput struct {
|
||||
// Dates []ext.ImportantDate `json:"dates"`
|
||||
// }
|
||||
|
||||
func (t *CalendarTool) GetUpcomingDates(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingDatesInput) (*mcp.CallToolResult, GetUpcomingDatesOutput, error) {
|
||||
dates, err := t.store.GetUpcomingDates(ctx, in.DaysAhead)
|
||||
if err != nil {
|
||||
return nil, GetUpcomingDatesOutput{}, err
|
||||
}
|
||||
if dates == nil {
|
||||
dates = []ext.ImportantDate{}
|
||||
}
|
||||
return nil, GetUpcomingDatesOutput{Dates: dates}, nil
|
||||
}
|
||||
// func (t *CalendarTool) GetUpcomingDates(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingDatesInput) (*mcp.CallToolResult, GetUpcomingDatesOutput, error) {
|
||||
// dates, err := t.store.GetUpcomingDates(ctx, in.DaysAhead)
|
||||
// if err != nil {
|
||||
// return nil, GetUpcomingDatesOutput{}, err
|
||||
// }
|
||||
// if dates == nil {
|
||||
// dates = []ext.ImportantDate{}
|
||||
// }
|
||||
// return nil, GetUpcomingDatesOutput{Dates: dates}, nil
|
||||
// }
|
||||
|
||||
@@ -1,240 +1,240 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "errors"
|
||||
// "fmt"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/jackc/pgx/v5"
|
||||
// "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/store"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
type CRMTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
// type CRMTool struct {
|
||||
// store *store.DB
|
||||
// }
|
||||
|
||||
func NewCRMTool(db *store.DB) *CRMTool {
|
||||
return &CRMTool{store: db}
|
||||
}
|
||||
// func NewCRMTool(db *store.DB) *CRMTool {
|
||||
// return &CRMTool{store: db}
|
||||
// }
|
||||
|
||||
// add_professional_contact
|
||||
// // add_professional_contact
|
||||
|
||||
type AddContactInput struct {
|
||||
Name string `json:"name" jsonschema:"contact's full name"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Title string `json:"title,omitempty" jsonschema:"job title"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
LinkedInURL string `json:"linkedin_url,omitempty"`
|
||||
HowWeMet string `json:"how_we_met,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
|
||||
}
|
||||
// type AddContactInput struct {
|
||||
// Name string `json:"name" jsonschema:"contact's full name"`
|
||||
// Company string `json:"company,omitempty"`
|
||||
// Title string `json:"title,omitempty" jsonschema:"job title"`
|
||||
// Email string `json:"email,omitempty"`
|
||||
// Phone string `json:"phone,omitempty"`
|
||||
// LinkedInURL string `json:"linkedin_url,omitempty"`
|
||||
// HowWeMet string `json:"how_we_met,omitempty"`
|
||||
// Tags []string `json:"tags,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
|
||||
// }
|
||||
|
||||
type AddContactOutput struct {
|
||||
Contact ext.ProfessionalContact `json:"contact"`
|
||||
}
|
||||
// type AddContactOutput struct {
|
||||
// Contact ext.ProfessionalContact `json:"contact"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddContactOutput{}, errRequiredField("name")
|
||||
}
|
||||
if in.Tags == nil {
|
||||
in.Tags = []string{}
|
||||
}
|
||||
contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Company: strings.TrimSpace(in.Company),
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Email: strings.TrimSpace(in.Email),
|
||||
Phone: strings.TrimSpace(in.Phone),
|
||||
LinkedInURL: strings.TrimSpace(in.LinkedInURL),
|
||||
HowWeMet: strings.TrimSpace(in.HowWeMet),
|
||||
Tags: in.Tags,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
FollowUpDate: in.FollowUpDate,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddContactOutput{}, err
|
||||
}
|
||||
return nil, AddContactOutput{Contact: contact}, nil
|
||||
}
|
||||
// func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddContactOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// if in.Tags == nil {
|
||||
// in.Tags = []string{}
|
||||
// }
|
||||
// contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Company: strings.TrimSpace(in.Company),
|
||||
// Title: strings.TrimSpace(in.Title),
|
||||
// Email: strings.TrimSpace(in.Email),
|
||||
// Phone: strings.TrimSpace(in.Phone),
|
||||
// LinkedInURL: strings.TrimSpace(in.LinkedInURL),
|
||||
// HowWeMet: strings.TrimSpace(in.HowWeMet),
|
||||
// Tags: in.Tags,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// FollowUpDate: in.FollowUpDate,
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddContactOutput{}, err
|
||||
// }
|
||||
// return nil, AddContactOutput{Contact: contact}, nil
|
||||
// }
|
||||
|
||||
// search_contacts
|
||||
// // search_contacts
|
||||
|
||||
type SearchContactsInput struct {
|
||||
Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"`
|
||||
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||
}
|
||||
// type SearchContactsInput struct {
|
||||
// Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"`
|
||||
// Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||
// }
|
||||
|
||||
type SearchContactsOutput struct {
|
||||
Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||
}
|
||||
// type SearchContactsOutput struct {
|
||||
// Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) {
|
||||
contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags)
|
||||
if err != nil {
|
||||
return nil, SearchContactsOutput{}, err
|
||||
}
|
||||
if contacts == nil {
|
||||
contacts = []ext.ProfessionalContact{}
|
||||
}
|
||||
return nil, SearchContactsOutput{Contacts: contacts}, nil
|
||||
}
|
||||
// func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) {
|
||||
// contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags)
|
||||
// if err != nil {
|
||||
// return nil, SearchContactsOutput{}, err
|
||||
// }
|
||||
// if contacts == nil {
|
||||
// contacts = []ext.ProfessionalContact{}
|
||||
// }
|
||||
// return nil, SearchContactsOutput{Contacts: contacts}, nil
|
||||
// }
|
||||
|
||||
// log_interaction
|
||||
// // log_interaction
|
||||
|
||||
type LogInteractionInput struct {
|
||||
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"`
|
||||
OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"`
|
||||
Summary string `json:"summary" jsonschema:"summary of the interaction"`
|
||||
FollowUpNeeded bool `json:"follow_up_needed,omitempty"`
|
||||
FollowUpNotes string `json:"follow_up_notes,omitempty"`
|
||||
}
|
||||
// type LogInteractionInput struct {
|
||||
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
// InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"`
|
||||
// OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"`
|
||||
// Summary string `json:"summary" jsonschema:"summary of the interaction"`
|
||||
// FollowUpNeeded bool `json:"follow_up_needed,omitempty"`
|
||||
// FollowUpNotes string `json:"follow_up_notes,omitempty"`
|
||||
// }
|
||||
|
||||
type LogInteractionOutput struct {
|
||||
Interaction ext.ContactInteraction `json:"interaction"`
|
||||
}
|
||||
// type LogInteractionOutput struct {
|
||||
// Interaction ext.ContactInteraction `json:"interaction"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
|
||||
if strings.TrimSpace(in.Summary) == "" {
|
||||
return nil, LogInteractionOutput{}, errRequiredField("summary")
|
||||
}
|
||||
occurredAt := time.Now()
|
||||
if in.OccurredAt != nil {
|
||||
occurredAt = *in.OccurredAt
|
||||
}
|
||||
interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{
|
||||
ContactID: in.ContactID,
|
||||
InteractionType: in.InteractionType,
|
||||
OccurredAt: occurredAt,
|
||||
Summary: strings.TrimSpace(in.Summary),
|
||||
FollowUpNeeded: in.FollowUpNeeded,
|
||||
FollowUpNotes: strings.TrimSpace(in.FollowUpNotes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, LogInteractionOutput{}, err
|
||||
}
|
||||
return nil, LogInteractionOutput{Interaction: interaction}, nil
|
||||
}
|
||||
// func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
|
||||
// if strings.TrimSpace(in.Summary) == "" {
|
||||
// return nil, LogInteractionOutput{}, errRequiredField("summary")
|
||||
// }
|
||||
// occurredAt := time.Now()
|
||||
// if in.OccurredAt != nil {
|
||||
// occurredAt = *in.OccurredAt
|
||||
// }
|
||||
// interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{
|
||||
// ContactID: in.ContactID,
|
||||
// InteractionType: in.InteractionType,
|
||||
// OccurredAt: occurredAt,
|
||||
// Summary: strings.TrimSpace(in.Summary),
|
||||
// FollowUpNeeded: in.FollowUpNeeded,
|
||||
// FollowUpNotes: strings.TrimSpace(in.FollowUpNotes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, LogInteractionOutput{}, err
|
||||
// }
|
||||
// return nil, LogInteractionOutput{Interaction: interaction}, nil
|
||||
// }
|
||||
|
||||
// get_contact_history
|
||||
// // get_contact_history
|
||||
|
||||
type GetContactHistoryInput struct {
|
||||
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
}
|
||||
// type GetContactHistoryInput struct {
|
||||
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
// }
|
||||
|
||||
type GetContactHistoryOutput struct {
|
||||
History ext.ContactHistory `json:"history"`
|
||||
}
|
||||
// type GetContactHistoryOutput struct {
|
||||
// History ext.ContactHistory `json:"history"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) {
|
||||
history, err := t.store.GetContactHistory(ctx, in.ContactID)
|
||||
if err != nil {
|
||||
return nil, GetContactHistoryOutput{}, err
|
||||
}
|
||||
return nil, GetContactHistoryOutput{History: history}, nil
|
||||
}
|
||||
// func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) {
|
||||
// history, err := t.store.GetContactHistory(ctx, in.ContactID)
|
||||
// if err != nil {
|
||||
// return nil, GetContactHistoryOutput{}, err
|
||||
// }
|
||||
// return nil, GetContactHistoryOutput{History: history}, nil
|
||||
// }
|
||||
|
||||
// create_opportunity
|
||||
// // create_opportunity
|
||||
|
||||
type CreateOpportunityInput struct {
|
||||
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||
Title string `json:"title" jsonschema:"opportunity title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"`
|
||||
Value *float64 `json:"value,omitempty" jsonschema:"monetary value"`
|
||||
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type CreateOpportunityInput struct {
|
||||
// ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||
// Title string `json:"title" jsonschema:"opportunity title"`
|
||||
// Description string `json:"description,omitempty"`
|
||||
// Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"`
|
||||
// Value *float64 `json:"value,omitempty" jsonschema:"monetary value"`
|
||||
// ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type CreateOpportunityOutput struct {
|
||||
Opportunity ext.Opportunity `json:"opportunity"`
|
||||
}
|
||||
// type CreateOpportunityOutput struct {
|
||||
// Opportunity ext.Opportunity `json:"opportunity"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, CreateOpportunityOutput{}, errRequiredField("title")
|
||||
}
|
||||
stage := strings.TrimSpace(in.Stage)
|
||||
if stage == "" {
|
||||
stage = "identified"
|
||||
}
|
||||
opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{
|
||||
ContactID: in.ContactID,
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
Stage: stage,
|
||||
Value: in.Value,
|
||||
ExpectedCloseDate: in.ExpectedCloseDate,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, CreateOpportunityOutput{}, err
|
||||
}
|
||||
return nil, CreateOpportunityOutput{Opportunity: opp}, nil
|
||||
}
|
||||
// func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) {
|
||||
// if strings.TrimSpace(in.Title) == "" {
|
||||
// return nil, CreateOpportunityOutput{}, errRequiredField("title")
|
||||
// }
|
||||
// stage := strings.TrimSpace(in.Stage)
|
||||
// if stage == "" {
|
||||
// stage = "identified"
|
||||
// }
|
||||
// opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{
|
||||
// ContactID: in.ContactID,
|
||||
// Title: strings.TrimSpace(in.Title),
|
||||
// Description: strings.TrimSpace(in.Description),
|
||||
// Stage: stage,
|
||||
// Value: in.Value,
|
||||
// ExpectedCloseDate: in.ExpectedCloseDate,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, CreateOpportunityOutput{}, err
|
||||
// }
|
||||
// return nil, CreateOpportunityOutput{Opportunity: opp}, nil
|
||||
// }
|
||||
|
||||
// get_follow_ups_due
|
||||
// // get_follow_ups_due
|
||||
|
||||
type GetFollowUpsDueInput struct {
|
||||
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"`
|
||||
}
|
||||
// type GetFollowUpsDueInput struct {
|
||||
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"`
|
||||
// }
|
||||
|
||||
type GetFollowUpsDueOutput struct {
|
||||
Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||
}
|
||||
// type GetFollowUpsDueOutput struct {
|
||||
// Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) {
|
||||
contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead)
|
||||
if err != nil {
|
||||
return nil, GetFollowUpsDueOutput{}, err
|
||||
}
|
||||
if contacts == nil {
|
||||
contacts = []ext.ProfessionalContact{}
|
||||
}
|
||||
return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil
|
||||
}
|
||||
// func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) {
|
||||
// contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead)
|
||||
// if err != nil {
|
||||
// return nil, GetFollowUpsDueOutput{}, err
|
||||
// }
|
||||
// if contacts == nil {
|
||||
// contacts = []ext.ProfessionalContact{}
|
||||
// }
|
||||
// return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil
|
||||
// }
|
||||
|
||||
// link_thought_to_contact
|
||||
// // link_thought_to_contact
|
||||
|
||||
type LinkThoughtToContactInput struct {
|
||||
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"`
|
||||
}
|
||||
// type LinkThoughtToContactInput struct {
|
||||
// ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||
// ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"`
|
||||
// }
|
||||
|
||||
type LinkThoughtToContactOutput struct {
|
||||
Contact ext.ProfessionalContact `json:"contact"`
|
||||
}
|
||||
// type LinkThoughtToContactOutput struct {
|
||||
// Contact ext.ProfessionalContact `json:"contact"`
|
||||
// }
|
||||
|
||||
func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) {
|
||||
thought, err := t.store.GetThought(ctx, in.ThoughtID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, LinkThoughtToContactOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID.String())
|
||||
}
|
||||
return nil, LinkThoughtToContactOutput{}, err
|
||||
}
|
||||
// func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) {
|
||||
// thought, err := t.store.GetThought(ctx, in.ThoughtID)
|
||||
// if err != nil {
|
||||
// if errors.Is(err, pgx.ErrNoRows) {
|
||||
// return nil, LinkThoughtToContactOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID.String())
|
||||
// }
|
||||
// return nil, LinkThoughtToContactOutput{}, err
|
||||
// }
|
||||
|
||||
appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content)
|
||||
if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil {
|
||||
return nil, LinkThoughtToContactOutput{}, err
|
||||
}
|
||||
// appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content)
|
||||
// if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil {
|
||||
// return nil, LinkThoughtToContactOutput{}, err
|
||||
// }
|
||||
|
||||
contact, err := t.store.GetContact(ctx, in.ContactID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, LinkThoughtToContactOutput{}, errEntityNotFound("contact", "contact_id", in.ContactID.String())
|
||||
}
|
||||
return nil, LinkThoughtToContactOutput{}, err
|
||||
}
|
||||
return nil, LinkThoughtToContactOutput{Contact: contact}, nil
|
||||
}
|
||||
// contact, err := t.store.GetContact(ctx, in.ContactID)
|
||||
// if err != nil {
|
||||
// if errors.Is(err, pgx.ErrNoRows) {
|
||||
// return nil, LinkThoughtToContactOutput{}, errEntityNotFound("contact", "contact_id", in.ContactID.String())
|
||||
// }
|
||||
// return nil, LinkThoughtToContactOutput{}, err
|
||||
// }
|
||||
// return nil, LinkThoughtToContactOutput{Contact: contact}, nil
|
||||
// }
|
||||
|
||||
@@ -1,151 +1,151 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
// import (
|
||||
// "context"
|
||||
// "strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/store"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
type HouseholdTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
// type HouseholdTool struct {
|
||||
// store *store.DB
|
||||
// }
|
||||
|
||||
func NewHouseholdTool(db *store.DB) *HouseholdTool {
|
||||
return &HouseholdTool{store: db}
|
||||
}
|
||||
// func NewHouseholdTool(db *store.DB) *HouseholdTool {
|
||||
// return &HouseholdTool{store: db}
|
||||
// }
|
||||
|
||||
// add_household_item
|
||||
// // add_household_item
|
||||
|
||||
type AddHouseholdItemInput struct {
|
||||
Name string `json:"name" jsonschema:"name of the item"`
|
||||
Category string `json:"category,omitempty" jsonschema:"category (e.g. paint, appliance, measurement, document)"`
|
||||
Location string `json:"location,omitempty" jsonschema:"where in the home this item is"`
|
||||
Details map[string]any `json:"details,omitempty" jsonschema:"flexible metadata (model numbers, colors, specs, etc.)"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddHouseholdItemInput struct {
|
||||
// Name string `json:"name" jsonschema:"name of the item"`
|
||||
// Category string `json:"category,omitempty" jsonschema:"category (e.g. paint, appliance, measurement, document)"`
|
||||
// Location string `json:"location,omitempty" jsonschema:"where in the home this item is"`
|
||||
// Details map[string]any `json:"details,omitempty" jsonschema:"flexible metadata (model numbers, colors, specs, etc.)"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddHouseholdItemOutput struct {
|
||||
Item ext.HouseholdItem `json:"item"`
|
||||
}
|
||||
// type AddHouseholdItemOutput struct {
|
||||
// Item ext.HouseholdItem `json:"item"`
|
||||
// }
|
||||
|
||||
func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddHouseholdItemOutput{}, errRequiredField("name")
|
||||
}
|
||||
if in.Details == nil {
|
||||
in.Details = map[string]any{}
|
||||
}
|
||||
item, err := t.store.AddHouseholdItem(ctx, ext.HouseholdItem{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Category: strings.TrimSpace(in.Category),
|
||||
Location: strings.TrimSpace(in.Location),
|
||||
Details: in.Details,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddHouseholdItemOutput{}, err
|
||||
}
|
||||
return nil, AddHouseholdItemOutput{Item: item}, nil
|
||||
}
|
||||
// func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddHouseholdItemOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// if in.Details == nil {
|
||||
// in.Details = map[string]any{}
|
||||
// }
|
||||
// item, err := t.store.AddHouseholdItem(ctx, ext.HouseholdItem{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Category: strings.TrimSpace(in.Category),
|
||||
// Location: strings.TrimSpace(in.Location),
|
||||
// Details: in.Details,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddHouseholdItemOutput{}, err
|
||||
// }
|
||||
// return nil, AddHouseholdItemOutput{Item: item}, nil
|
||||
// }
|
||||
|
||||
// search_household_items
|
||||
// // search_household_items
|
||||
|
||||
type SearchHouseholdItemsInput struct {
|
||||
Query string `json:"query,omitempty" jsonschema:"search text matching name or notes"`
|
||||
Category string `json:"category,omitempty" jsonschema:"filter by category"`
|
||||
Location string `json:"location,omitempty" jsonschema:"filter by location"`
|
||||
}
|
||||
// type SearchHouseholdItemsInput struct {
|
||||
// Query string `json:"query,omitempty" jsonschema:"search text matching name or notes"`
|
||||
// Category string `json:"category,omitempty" jsonschema:"filter by category"`
|
||||
// Location string `json:"location,omitempty" jsonschema:"filter by location"`
|
||||
// }
|
||||
|
||||
type SearchHouseholdItemsOutput struct {
|
||||
Items []ext.HouseholdItem `json:"items"`
|
||||
}
|
||||
// type SearchHouseholdItemsOutput struct {
|
||||
// Items []ext.HouseholdItem `json:"items"`
|
||||
// }
|
||||
|
||||
func (t *HouseholdTool) SearchItems(ctx context.Context, _ *mcp.CallToolRequest, in SearchHouseholdItemsInput) (*mcp.CallToolResult, SearchHouseholdItemsOutput, error) {
|
||||
items, err := t.store.SearchHouseholdItems(ctx, in.Query, in.Category, in.Location)
|
||||
if err != nil {
|
||||
return nil, SearchHouseholdItemsOutput{}, err
|
||||
}
|
||||
if items == nil {
|
||||
items = []ext.HouseholdItem{}
|
||||
}
|
||||
return nil, SearchHouseholdItemsOutput{Items: items}, nil
|
||||
}
|
||||
// func (t *HouseholdTool) SearchItems(ctx context.Context, _ *mcp.CallToolRequest, in SearchHouseholdItemsInput) (*mcp.CallToolResult, SearchHouseholdItemsOutput, error) {
|
||||
// items, err := t.store.SearchHouseholdItems(ctx, in.Query, in.Category, in.Location)
|
||||
// if err != nil {
|
||||
// return nil, SearchHouseholdItemsOutput{}, err
|
||||
// }
|
||||
// if items == nil {
|
||||
// items = []ext.HouseholdItem{}
|
||||
// }
|
||||
// return nil, SearchHouseholdItemsOutput{Items: items}, nil
|
||||
// }
|
||||
|
||||
// get_household_item
|
||||
// // get_household_item
|
||||
|
||||
type GetHouseholdItemInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"item id"`
|
||||
}
|
||||
// type GetHouseholdItemInput struct {
|
||||
// ID uuid.UUID `json:"id" jsonschema:"item id"`
|
||||
// }
|
||||
|
||||
type GetHouseholdItemOutput struct {
|
||||
Item ext.HouseholdItem `json:"item"`
|
||||
}
|
||||
// type GetHouseholdItemOutput struct {
|
||||
// Item ext.HouseholdItem `json:"item"`
|
||||
// }
|
||||
|
||||
func (t *HouseholdTool) GetItem(ctx context.Context, _ *mcp.CallToolRequest, in GetHouseholdItemInput) (*mcp.CallToolResult, GetHouseholdItemOutput, error) {
|
||||
item, err := t.store.GetHouseholdItem(ctx, in.ID)
|
||||
if err != nil {
|
||||
return nil, GetHouseholdItemOutput{}, err
|
||||
}
|
||||
return nil, GetHouseholdItemOutput{Item: item}, nil
|
||||
}
|
||||
// func (t *HouseholdTool) GetItem(ctx context.Context, _ *mcp.CallToolRequest, in GetHouseholdItemInput) (*mcp.CallToolResult, GetHouseholdItemOutput, error) {
|
||||
// item, err := t.store.GetHouseholdItem(ctx, in.ID)
|
||||
// if err != nil {
|
||||
// return nil, GetHouseholdItemOutput{}, err
|
||||
// }
|
||||
// return nil, GetHouseholdItemOutput{Item: item}, nil
|
||||
// }
|
||||
|
||||
// add_vendor
|
||||
// // add_vendor
|
||||
|
||||
type AddVendorInput struct {
|
||||
Name string `json:"name" jsonschema:"vendor name"`
|
||||
ServiceType string `json:"service_type,omitempty" jsonschema:"type of service (e.g. plumber, electrician, landscaper)"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||
}
|
||||
// type AddVendorInput struct {
|
||||
// Name string `json:"name" jsonschema:"vendor name"`
|
||||
// ServiceType string `json:"service_type,omitempty" jsonschema:"type of service (e.g. plumber, electrician, landscaper)"`
|
||||
// Phone string `json:"phone,omitempty"`
|
||||
// Email string `json:"email,omitempty"`
|
||||
// Website string `json:"website,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||
// }
|
||||
|
||||
type AddVendorOutput struct {
|
||||
Vendor ext.HouseholdVendor `json:"vendor"`
|
||||
}
|
||||
// type AddVendorOutput struct {
|
||||
// Vendor ext.HouseholdVendor `json:"vendor"`
|
||||
// }
|
||||
|
||||
func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddVendorOutput{}, errRequiredField("name")
|
||||
}
|
||||
vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
ServiceType: strings.TrimSpace(in.ServiceType),
|
||||
Phone: strings.TrimSpace(in.Phone),
|
||||
Email: strings.TrimSpace(in.Email),
|
||||
Website: strings.TrimSpace(in.Website),
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
Rating: in.Rating,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddVendorOutput{}, err
|
||||
}
|
||||
return nil, AddVendorOutput{Vendor: vendor}, nil
|
||||
}
|
||||
// func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddVendorOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// ServiceType: strings.TrimSpace(in.ServiceType),
|
||||
// Phone: strings.TrimSpace(in.Phone),
|
||||
// Email: strings.TrimSpace(in.Email),
|
||||
// Website: strings.TrimSpace(in.Website),
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// Rating: in.Rating,
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddVendorOutput{}, err
|
||||
// }
|
||||
// return nil, AddVendorOutput{Vendor: vendor}, nil
|
||||
// }
|
||||
|
||||
// list_vendors
|
||||
// // list_vendors
|
||||
|
||||
type ListVendorsInput struct {
|
||||
ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
|
||||
}
|
||||
// type ListVendorsInput struct {
|
||||
// ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
|
||||
// }
|
||||
|
||||
type ListVendorsOutput struct {
|
||||
Vendors []ext.HouseholdVendor `json:"vendors"`
|
||||
}
|
||||
// type ListVendorsOutput struct {
|
||||
// Vendors []ext.HouseholdVendor `json:"vendors"`
|
||||
// }
|
||||
|
||||
func (t *HouseholdTool) ListVendors(ctx context.Context, _ *mcp.CallToolRequest, in ListVendorsInput) (*mcp.CallToolResult, ListVendorsOutput, error) {
|
||||
vendors, err := t.store.ListVendors(ctx, in.ServiceType)
|
||||
if err != nil {
|
||||
return nil, ListVendorsOutput{}, err
|
||||
}
|
||||
if vendors == nil {
|
||||
vendors = []ext.HouseholdVendor{}
|
||||
}
|
||||
return nil, ListVendorsOutput{Vendors: vendors}, nil
|
||||
}
|
||||
// func (t *HouseholdTool) ListVendors(ctx context.Context, _ *mcp.CallToolRequest, in ListVendorsInput) (*mcp.CallToolResult, ListVendorsOutput, error) {
|
||||
// vendors, err := t.store.ListVendors(ctx, in.ServiceType)
|
||||
// if err != nil {
|
||||
// return nil, ListVendorsOutput{}, err
|
||||
// }
|
||||
// if vendors == nil {
|
||||
// vendors = []ext.HouseholdVendor{}
|
||||
// }
|
||||
// return nil, ListVendorsOutput{Vendors: vendors}, nil
|
||||
// }
|
||||
|
||||
@@ -1,137 +1,137 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/store"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
type MaintenanceTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
// type MaintenanceTool struct {
|
||||
// store *store.DB
|
||||
// }
|
||||
|
||||
func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
|
||||
return &MaintenanceTool{store: db}
|
||||
}
|
||||
// func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
|
||||
// return &MaintenanceTool{store: db}
|
||||
// }
|
||||
|
||||
// add_maintenance_task
|
||||
// // add_maintenance_task
|
||||
|
||||
type AddMaintenanceTaskInput struct {
|
||||
Name string `json:"name" jsonschema:"task name"`
|
||||
Category string `json:"category,omitempty" jsonschema:"e.g. hvac, plumbing, exterior, appliance, landscaping"`
|
||||
FrequencyDays *int `json:"frequency_days,omitempty" jsonschema:"recurrence interval in days; omit for one-time tasks"`
|
||||
NextDue *time.Time `json:"next_due,omitempty" jsonschema:"when the task is next due"`
|
||||
Priority string `json:"priority,omitempty" jsonschema:"low, medium, high, or urgent (default: medium)"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddMaintenanceTaskInput struct {
|
||||
// Name string `json:"name" jsonschema:"task name"`
|
||||
// Category string `json:"category,omitempty" jsonschema:"e.g. hvac, plumbing, exterior, appliance, landscaping"`
|
||||
// FrequencyDays *int `json:"frequency_days,omitempty" jsonschema:"recurrence interval in days; omit for one-time tasks"`
|
||||
// NextDue *time.Time `json:"next_due,omitempty" jsonschema:"when the task is next due"`
|
||||
// Priority string `json:"priority,omitempty" jsonschema:"low, medium, high, or urgent (default: medium)"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddMaintenanceTaskOutput struct {
|
||||
Task ext.MaintenanceTask `json:"task"`
|
||||
}
|
||||
// type AddMaintenanceTaskOutput struct {
|
||||
// Task ext.MaintenanceTask `json:"task"`
|
||||
// }
|
||||
|
||||
func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddMaintenanceTaskOutput{}, errRequiredField("name")
|
||||
}
|
||||
priority := strings.TrimSpace(in.Priority)
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
task, err := t.store.AddMaintenanceTask(ctx, ext.MaintenanceTask{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Category: strings.TrimSpace(in.Category),
|
||||
FrequencyDays: in.FrequencyDays,
|
||||
NextDue: in.NextDue,
|
||||
Priority: priority,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddMaintenanceTaskOutput{}, err
|
||||
}
|
||||
return nil, AddMaintenanceTaskOutput{Task: task}, nil
|
||||
}
|
||||
// func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddMaintenanceTaskOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// priority := strings.TrimSpace(in.Priority)
|
||||
// if priority == "" {
|
||||
// priority = "medium"
|
||||
// }
|
||||
// task, err := t.store.AddMaintenanceTask(ctx, ext.MaintenanceTask{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Category: strings.TrimSpace(in.Category),
|
||||
// FrequencyDays: in.FrequencyDays,
|
||||
// NextDue: in.NextDue,
|
||||
// Priority: priority,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddMaintenanceTaskOutput{}, err
|
||||
// }
|
||||
// return nil, AddMaintenanceTaskOutput{Task: task}, nil
|
||||
// }
|
||||
|
||||
// log_maintenance
|
||||
// // log_maintenance
|
||||
|
||||
type LogMaintenanceInput struct {
|
||||
TaskID uuid.UUID `json:"task_id" jsonschema:"id of the maintenance task"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" jsonschema:"when the work was done (defaults to now)"`
|
||||
PerformedBy string `json:"performed_by,omitempty" jsonschema:"who did the work (self, vendor name, etc.)"`
|
||||
Cost *float64 `json:"cost,omitempty" jsonschema:"cost of the work"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NextAction string `json:"next_action,omitempty" jsonschema:"recommended follow-up"`
|
||||
}
|
||||
// type LogMaintenanceInput struct {
|
||||
// TaskID uuid.UUID `json:"task_id" jsonschema:"id of the maintenance task"`
|
||||
// CompletedAt *time.Time `json:"completed_at,omitempty" jsonschema:"when the work was done (defaults to now)"`
|
||||
// PerformedBy string `json:"performed_by,omitempty" jsonschema:"who did the work (self, vendor name, etc.)"`
|
||||
// Cost *float64 `json:"cost,omitempty" jsonschema:"cost of the work"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// NextAction string `json:"next_action,omitempty" jsonschema:"recommended follow-up"`
|
||||
// }
|
||||
|
||||
type LogMaintenanceOutput struct {
|
||||
Log ext.MaintenanceLog `json:"log"`
|
||||
}
|
||||
// type LogMaintenanceOutput struct {
|
||||
// Log ext.MaintenanceLog `json:"log"`
|
||||
// }
|
||||
|
||||
func (t *MaintenanceTool) LogWork(ctx context.Context, _ *mcp.CallToolRequest, in LogMaintenanceInput) (*mcp.CallToolResult, LogMaintenanceOutput, error) {
|
||||
completedAt := time.Now()
|
||||
if in.CompletedAt != nil {
|
||||
completedAt = *in.CompletedAt
|
||||
}
|
||||
log, err := t.store.LogMaintenance(ctx, ext.MaintenanceLog{
|
||||
TaskID: in.TaskID,
|
||||
CompletedAt: completedAt,
|
||||
PerformedBy: strings.TrimSpace(in.PerformedBy),
|
||||
Cost: in.Cost,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
NextAction: strings.TrimSpace(in.NextAction),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, LogMaintenanceOutput{}, err
|
||||
}
|
||||
return nil, LogMaintenanceOutput{Log: log}, nil
|
||||
}
|
||||
// func (t *MaintenanceTool) LogWork(ctx context.Context, _ *mcp.CallToolRequest, in LogMaintenanceInput) (*mcp.CallToolResult, LogMaintenanceOutput, error) {
|
||||
// completedAt := time.Now()
|
||||
// if in.CompletedAt != nil {
|
||||
// completedAt = *in.CompletedAt
|
||||
// }
|
||||
// log, err := t.store.LogMaintenance(ctx, ext.MaintenanceLog{
|
||||
// TaskID: in.TaskID,
|
||||
// CompletedAt: completedAt,
|
||||
// PerformedBy: strings.TrimSpace(in.PerformedBy),
|
||||
// Cost: in.Cost,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// NextAction: strings.TrimSpace(in.NextAction),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, LogMaintenanceOutput{}, err
|
||||
// }
|
||||
// return nil, LogMaintenanceOutput{Log: log}, nil
|
||||
// }
|
||||
|
||||
// get_upcoming_maintenance
|
||||
// // get_upcoming_maintenance
|
||||
|
||||
type GetUpcomingMaintenanceInput struct {
|
||||
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||
}
|
||||
// type GetUpcomingMaintenanceInput struct {
|
||||
// DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||
// }
|
||||
|
||||
type GetUpcomingMaintenanceOutput struct {
|
||||
Tasks []ext.MaintenanceTask `json:"tasks"`
|
||||
}
|
||||
// type GetUpcomingMaintenanceOutput struct {
|
||||
// Tasks []ext.MaintenanceTask `json:"tasks"`
|
||||
// }
|
||||
|
||||
func (t *MaintenanceTool) GetUpcoming(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingMaintenanceInput) (*mcp.CallToolResult, GetUpcomingMaintenanceOutput, error) {
|
||||
tasks, err := t.store.GetUpcomingMaintenance(ctx, in.DaysAhead)
|
||||
if err != nil {
|
||||
return nil, GetUpcomingMaintenanceOutput{}, err
|
||||
}
|
||||
if tasks == nil {
|
||||
tasks = []ext.MaintenanceTask{}
|
||||
}
|
||||
return nil, GetUpcomingMaintenanceOutput{Tasks: tasks}, nil
|
||||
}
|
||||
// func (t *MaintenanceTool) GetUpcoming(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingMaintenanceInput) (*mcp.CallToolResult, GetUpcomingMaintenanceOutput, error) {
|
||||
// tasks, err := t.store.GetUpcomingMaintenance(ctx, in.DaysAhead)
|
||||
// if err != nil {
|
||||
// return nil, GetUpcomingMaintenanceOutput{}, err
|
||||
// }
|
||||
// if tasks == nil {
|
||||
// tasks = []ext.MaintenanceTask{}
|
||||
// }
|
||||
// return nil, GetUpcomingMaintenanceOutput{Tasks: tasks}, nil
|
||||
// }
|
||||
|
||||
// search_maintenance_history
|
||||
// // search_maintenance_history
|
||||
|
||||
type SearchMaintenanceHistoryInput struct {
|
||||
Query string `json:"query,omitempty" jsonschema:"search text matching task name or notes"`
|
||||
Category string `json:"category,omitempty" jsonschema:"filter by task category"`
|
||||
Start *time.Time `json:"start,omitempty" jsonschema:"filter logs completed on or after this date"`
|
||||
End *time.Time `json:"end,omitempty" jsonschema:"filter logs completed on or before this date"`
|
||||
}
|
||||
// type SearchMaintenanceHistoryInput struct {
|
||||
// Query string `json:"query,omitempty" jsonschema:"search text matching task name or notes"`
|
||||
// Category string `json:"category,omitempty" jsonschema:"filter by task category"`
|
||||
// Start *time.Time `json:"start,omitempty" jsonschema:"filter logs completed on or after this date"`
|
||||
// End *time.Time `json:"end,omitempty" jsonschema:"filter logs completed on or before this date"`
|
||||
// }
|
||||
|
||||
type SearchMaintenanceHistoryOutput struct {
|
||||
Logs []ext.MaintenanceLogWithTask `json:"logs"`
|
||||
}
|
||||
// type SearchMaintenanceHistoryOutput struct {
|
||||
// Logs []ext.MaintenanceLogWithTask `json:"logs"`
|
||||
// }
|
||||
|
||||
func (t *MaintenanceTool) SearchHistory(ctx context.Context, _ *mcp.CallToolRequest, in SearchMaintenanceHistoryInput) (*mcp.CallToolResult, SearchMaintenanceHistoryOutput, error) {
|
||||
logs, err := t.store.SearchMaintenanceHistory(ctx, in.Query, in.Category, in.Start, in.End)
|
||||
if err != nil {
|
||||
return nil, SearchMaintenanceHistoryOutput{}, err
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []ext.MaintenanceLogWithTask{}
|
||||
}
|
||||
return nil, SearchMaintenanceHistoryOutput{Logs: logs}, nil
|
||||
}
|
||||
// func (t *MaintenanceTool) SearchHistory(ctx context.Context, _ *mcp.CallToolRequest, in SearchMaintenanceHistoryInput) (*mcp.CallToolResult, SearchMaintenanceHistoryOutput, error) {
|
||||
// logs, err := t.store.SearchMaintenanceHistory(ctx, in.Query, in.Category, in.Start, in.End)
|
||||
// if err != nil {
|
||||
// return nil, SearchMaintenanceHistoryOutput{}, err
|
||||
// }
|
||||
// if logs == nil {
|
||||
// logs = []ext.MaintenanceLogWithTask{}
|
||||
// }
|
||||
// return nil, SearchMaintenanceHistoryOutput{Logs: logs}, nil
|
||||
// }
|
||||
|
||||
@@ -1,210 +1,210 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
// import (
|
||||
// "context"
|
||||
// "strings"
|
||||
// "time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
// "github.com/google/uuid"
|
||||
// "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
// "git.warky.dev/wdevs/amcs/internal/store"
|
||||
// ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
// )
|
||||
|
||||
type MealsTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
// type MealsTool struct {
|
||||
// store *store.DB
|
||||
// }
|
||||
|
||||
func NewMealsTool(db *store.DB) *MealsTool {
|
||||
return &MealsTool{store: db}
|
||||
}
|
||||
// func NewMealsTool(db *store.DB) *MealsTool {
|
||||
// return &MealsTool{store: db}
|
||||
// }
|
||||
|
||||
// add_recipe
|
||||
// // add_recipe
|
||||
|
||||
type AddRecipeInput struct {
|
||||
Name string `json:"name" jsonschema:"recipe name"`
|
||||
Cuisine string `json:"cuisine,omitempty"`
|
||||
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||
Servings *int `json:"servings,omitempty"`
|
||||
Ingredients []ext.Ingredient `json:"ingredients,omitempty" jsonschema:"list of ingredients with name, quantity, unit"`
|
||||
Instructions []string `json:"instructions,omitempty" jsonschema:"ordered list of steps"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type AddRecipeInput struct {
|
||||
// Name string `json:"name" jsonschema:"recipe name"`
|
||||
// Cuisine string `json:"cuisine,omitempty"`
|
||||
// PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||
// CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||
// Servings *int `json:"servings,omitempty"`
|
||||
// Ingredients []ext.Ingredient `json:"ingredients,omitempty" jsonschema:"list of ingredients with name, quantity, unit"`
|
||||
// Instructions []string `json:"instructions,omitempty" jsonschema:"ordered list of steps"`
|
||||
// Tags []string `json:"tags,omitempty"`
|
||||
// Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type AddRecipeOutput struct {
|
||||
Recipe ext.Recipe `json:"recipe"`
|
||||
}
|
||||
// type AddRecipeOutput struct {
|
||||
// Recipe ext.Recipe `json:"recipe"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, AddRecipeOutput{}, errRequiredField("name")
|
||||
}
|
||||
if in.Ingredients == nil {
|
||||
in.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if in.Instructions == nil {
|
||||
in.Instructions = []string{}
|
||||
}
|
||||
if in.Tags == nil {
|
||||
in.Tags = []string{}
|
||||
}
|
||||
recipe, err := t.store.AddRecipe(ctx, ext.Recipe{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Cuisine: strings.TrimSpace(in.Cuisine),
|
||||
PrepTimeMinutes: in.PrepTimeMinutes,
|
||||
CookTimeMinutes: in.CookTimeMinutes,
|
||||
Servings: in.Servings,
|
||||
Ingredients: in.Ingredients,
|
||||
Instructions: in.Instructions,
|
||||
Tags: in.Tags,
|
||||
Rating: in.Rating,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddRecipeOutput{}, err
|
||||
}
|
||||
return nil, AddRecipeOutput{Recipe: recipe}, nil
|
||||
}
|
||||
// func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, AddRecipeOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// if in.Ingredients == nil {
|
||||
// in.Ingredients = []ext.Ingredient{}
|
||||
// }
|
||||
// if in.Instructions == nil {
|
||||
// in.Instructions = []string{}
|
||||
// }
|
||||
// if in.Tags == nil {
|
||||
// in.Tags = []string{}
|
||||
// }
|
||||
// recipe, err := t.store.AddRecipe(ctx, ext.Recipe{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Cuisine: strings.TrimSpace(in.Cuisine),
|
||||
// PrepTimeMinutes: in.PrepTimeMinutes,
|
||||
// CookTimeMinutes: in.CookTimeMinutes,
|
||||
// Servings: in.Servings,
|
||||
// Ingredients: in.Ingredients,
|
||||
// Instructions: in.Instructions,
|
||||
// Tags: in.Tags,
|
||||
// Rating: in.Rating,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, AddRecipeOutput{}, err
|
||||
// }
|
||||
// return nil, AddRecipeOutput{Recipe: recipe}, nil
|
||||
// }
|
||||
|
||||
// search_recipes
|
||||
// // search_recipes
|
||||
|
||||
type SearchRecipesInput struct {
|
||||
Query string `json:"query,omitempty" jsonschema:"search by recipe name"`
|
||||
Cuisine string `json:"cuisine,omitempty"`
|
||||
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||
Ingredient string `json:"ingredient,omitempty" jsonschema:"filter by ingredient name"`
|
||||
}
|
||||
// type SearchRecipesInput struct {
|
||||
// Query string `json:"query,omitempty" jsonschema:"search by recipe name"`
|
||||
// Cuisine string `json:"cuisine,omitempty"`
|
||||
// Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||
// Ingredient string `json:"ingredient,omitempty" jsonschema:"filter by ingredient name"`
|
||||
// }
|
||||
|
||||
type SearchRecipesOutput struct {
|
||||
Recipes []ext.Recipe `json:"recipes"`
|
||||
}
|
||||
// type SearchRecipesOutput struct {
|
||||
// Recipes []ext.Recipe `json:"recipes"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) SearchRecipes(ctx context.Context, _ *mcp.CallToolRequest, in SearchRecipesInput) (*mcp.CallToolResult, SearchRecipesOutput, error) {
|
||||
recipes, err := t.store.SearchRecipes(ctx, in.Query, in.Cuisine, in.Tags, in.Ingredient)
|
||||
if err != nil {
|
||||
return nil, SearchRecipesOutput{}, err
|
||||
}
|
||||
if recipes == nil {
|
||||
recipes = []ext.Recipe{}
|
||||
}
|
||||
return nil, SearchRecipesOutput{Recipes: recipes}, nil
|
||||
}
|
||||
// func (t *MealsTool) SearchRecipes(ctx context.Context, _ *mcp.CallToolRequest, in SearchRecipesInput) (*mcp.CallToolResult, SearchRecipesOutput, error) {
|
||||
// recipes, err := t.store.SearchRecipes(ctx, in.Query, in.Cuisine, in.Tags, in.Ingredient)
|
||||
// if err != nil {
|
||||
// return nil, SearchRecipesOutput{}, err
|
||||
// }
|
||||
// if recipes == nil {
|
||||
// recipes = []ext.Recipe{}
|
||||
// }
|
||||
// return nil, SearchRecipesOutput{Recipes: recipes}, nil
|
||||
// }
|
||||
|
||||
// update_recipe
|
||||
// // update_recipe
|
||||
|
||||
type UpdateRecipeInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"recipe id to update"`
|
||||
Name string `json:"name" jsonschema:"recipe name"`
|
||||
Cuisine string `json:"cuisine,omitempty"`
|
||||
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||
Servings *int `json:"servings,omitempty"`
|
||||
Ingredients []ext.Ingredient `json:"ingredients,omitempty"`
|
||||
Instructions []string `json:"instructions,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Rating *int `json:"rating,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
// type UpdateRecipeInput struct {
|
||||
// ID uuid.UUID `json:"id" jsonschema:"recipe id to update"`
|
||||
// Name string `json:"name" jsonschema:"recipe name"`
|
||||
// Cuisine string `json:"cuisine,omitempty"`
|
||||
// PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||
// CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||
// Servings *int `json:"servings,omitempty"`
|
||||
// Ingredients []ext.Ingredient `json:"ingredients,omitempty"`
|
||||
// Instructions []string `json:"instructions,omitempty"`
|
||||
// Tags []string `json:"tags,omitempty"`
|
||||
// Rating *int `json:"rating,omitempty"`
|
||||
// Notes string `json:"notes,omitempty"`
|
||||
// }
|
||||
|
||||
type UpdateRecipeOutput struct {
|
||||
Recipe ext.Recipe `json:"recipe"`
|
||||
}
|
||||
// type UpdateRecipeOutput struct {
|
||||
// Recipe ext.Recipe `json:"recipe"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, UpdateRecipeOutput{}, errRequiredField("name")
|
||||
}
|
||||
if in.Ingredients == nil {
|
||||
in.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if in.Instructions == nil {
|
||||
in.Instructions = []string{}
|
||||
}
|
||||
if in.Tags == nil {
|
||||
in.Tags = []string{}
|
||||
}
|
||||
recipe, err := t.store.UpdateRecipe(ctx, in.ID, ext.Recipe{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Cuisine: strings.TrimSpace(in.Cuisine),
|
||||
PrepTimeMinutes: in.PrepTimeMinutes,
|
||||
CookTimeMinutes: in.CookTimeMinutes,
|
||||
Servings: in.Servings,
|
||||
Ingredients: in.Ingredients,
|
||||
Instructions: in.Instructions,
|
||||
Tags: in.Tags,
|
||||
Rating: in.Rating,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, UpdateRecipeOutput{}, err
|
||||
}
|
||||
return nil, UpdateRecipeOutput{Recipe: recipe}, nil
|
||||
}
|
||||
// func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
|
||||
// if strings.TrimSpace(in.Name) == "" {
|
||||
// return nil, UpdateRecipeOutput{}, errRequiredField("name")
|
||||
// }
|
||||
// if in.Ingredients == nil {
|
||||
// in.Ingredients = []ext.Ingredient{}
|
||||
// }
|
||||
// if in.Instructions == nil {
|
||||
// in.Instructions = []string{}
|
||||
// }
|
||||
// if in.Tags == nil {
|
||||
// in.Tags = []string{}
|
||||
// }
|
||||
// recipe, err := t.store.UpdateRecipe(ctx, in.ID, ext.Recipe{
|
||||
// Name: strings.TrimSpace(in.Name),
|
||||
// Cuisine: strings.TrimSpace(in.Cuisine),
|
||||
// PrepTimeMinutes: in.PrepTimeMinutes,
|
||||
// CookTimeMinutes: in.CookTimeMinutes,
|
||||
// Servings: in.Servings,
|
||||
// Ingredients: in.Ingredients,
|
||||
// Instructions: in.Instructions,
|
||||
// Tags: in.Tags,
|
||||
// Rating: in.Rating,
|
||||
// Notes: strings.TrimSpace(in.Notes),
|
||||
// })
|
||||
// if err != nil {
|
||||
// return nil, UpdateRecipeOutput{}, err
|
||||
// }
|
||||
// return nil, UpdateRecipeOutput{Recipe: recipe}, nil
|
||||
// }
|
||||
|
||||
// create_meal_plan
|
||||
// // create_meal_plan
|
||||
|
||||
type CreateMealPlanInput struct {
|
||||
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to plan"`
|
||||
Meals []ext.MealPlanInput `json:"meals" jsonschema:"list of meal entries for the week"`
|
||||
}
|
||||
// type CreateMealPlanInput struct {
|
||||
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to plan"`
|
||||
// Meals []ext.MealPlanInput `json:"meals" jsonschema:"list of meal entries for the week"`
|
||||
// }
|
||||
|
||||
type CreateMealPlanOutput struct {
|
||||
Entries []ext.MealPlanEntry `json:"entries"`
|
||||
}
|
||||
// type CreateMealPlanOutput struct {
|
||||
// Entries []ext.MealPlanEntry `json:"entries"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) CreateMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in CreateMealPlanInput) (*mcp.CallToolResult, CreateMealPlanOutput, error) {
|
||||
if len(in.Meals) == 0 {
|
||||
return nil, CreateMealPlanOutput{}, errInvalidInput("meals are required")
|
||||
}
|
||||
entries, err := t.store.CreateMealPlan(ctx, in.WeekStart, in.Meals)
|
||||
if err != nil {
|
||||
return nil, CreateMealPlanOutput{}, err
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []ext.MealPlanEntry{}
|
||||
}
|
||||
return nil, CreateMealPlanOutput{Entries: entries}, nil
|
||||
}
|
||||
// func (t *MealsTool) CreateMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in CreateMealPlanInput) (*mcp.CallToolResult, CreateMealPlanOutput, error) {
|
||||
// if len(in.Meals) == 0 {
|
||||
// return nil, CreateMealPlanOutput{}, errInvalidInput("meals are required")
|
||||
// }
|
||||
// entries, err := t.store.CreateMealPlan(ctx, in.WeekStart, in.Meals)
|
||||
// if err != nil {
|
||||
// return nil, CreateMealPlanOutput{}, err
|
||||
// }
|
||||
// if entries == nil {
|
||||
// entries = []ext.MealPlanEntry{}
|
||||
// }
|
||||
// return nil, CreateMealPlanOutput{Entries: entries}, nil
|
||||
// }
|
||||
|
||||
// get_meal_plan
|
||||
// // get_meal_plan
|
||||
|
||||
type GetMealPlanInput struct {
|
||||
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
|
||||
}
|
||||
// type GetMealPlanInput struct {
|
||||
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
|
||||
// }
|
||||
|
||||
type GetMealPlanOutput struct {
|
||||
Entries []ext.MealPlanEntry `json:"entries"`
|
||||
}
|
||||
// type GetMealPlanOutput struct {
|
||||
// Entries []ext.MealPlanEntry `json:"entries"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) GetMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in GetMealPlanInput) (*mcp.CallToolResult, GetMealPlanOutput, error) {
|
||||
entries, err := t.store.GetMealPlan(ctx, in.WeekStart)
|
||||
if err != nil {
|
||||
return nil, GetMealPlanOutput{}, err
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []ext.MealPlanEntry{}
|
||||
}
|
||||
return nil, GetMealPlanOutput{Entries: entries}, nil
|
||||
}
|
||||
// func (t *MealsTool) GetMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in GetMealPlanInput) (*mcp.CallToolResult, GetMealPlanOutput, error) {
|
||||
// entries, err := t.store.GetMealPlan(ctx, in.WeekStart)
|
||||
// if err != nil {
|
||||
// return nil, GetMealPlanOutput{}, err
|
||||
// }
|
||||
// if entries == nil {
|
||||
// entries = []ext.MealPlanEntry{}
|
||||
// }
|
||||
// return nil, GetMealPlanOutput{Entries: entries}, nil
|
||||
// }
|
||||
|
||||
// generate_shopping_list
|
||||
// // generate_shopping_list
|
||||
|
||||
type GenerateShoppingListInput struct {
|
||||
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to generate shopping list for"`
|
||||
}
|
||||
// type GenerateShoppingListInput struct {
|
||||
// WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to generate shopping list for"`
|
||||
// }
|
||||
|
||||
type GenerateShoppingListOutput struct {
|
||||
ShoppingList ext.ShoppingList `json:"shopping_list"`
|
||||
}
|
||||
// type GenerateShoppingListOutput struct {
|
||||
// ShoppingList ext.ShoppingList `json:"shopping_list"`
|
||||
// }
|
||||
|
||||
func (t *MealsTool) GenerateShoppingList(ctx context.Context, _ *mcp.CallToolRequest, in GenerateShoppingListInput) (*mcp.CallToolResult, GenerateShoppingListOutput, error) {
|
||||
list, err := t.store.GenerateShoppingList(ctx, in.WeekStart)
|
||||
if err != nil {
|
||||
return nil, GenerateShoppingListOutput{}, err
|
||||
}
|
||||
return nil, GenerateShoppingListOutput{ShoppingList: list}, nil
|
||||
}
|
||||
// func (t *MealsTool) GenerateShoppingList(ctx context.Context, _ *mcp.CallToolRequest, in GenerateShoppingListInput) (*mcp.CallToolResult, GenerateShoppingListOutput, error) {
|
||||
// list, err := t.store.GenerateShoppingList(ctx, in.WeekStart)
|
||||
// if err != nil {
|
||||
// return nil, GenerateShoppingListOutput{}, err
|
||||
// }
|
||||
// return nil, GenerateShoppingListOutput{ShoppingList: list}, nil
|
||||
// }
|
||||
|
||||
344
internal/tools/plans.go
Normal file
344
internal/tools/plans.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
"git.warky.dev/wdevs/amcs/internal/session"
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
type PlansTool struct {
|
||||
store *store.DB
|
||||
sessions *session.ActiveProjects
|
||||
cfg config.SearchConfig
|
||||
}
|
||||
|
||||
func NewPlansTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *PlansTool {
|
||||
return &PlansTool{store: db, sessions: sessions, cfg: cfg}
|
||||
}
|
||||
|
||||
// --- I/O types ---
|
||||
|
||||
type CreatePlanInput struct {
|
||||
Title string `json:"title" jsonschema:"plan title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
|
||||
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
|
||||
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339 timestamp"`
|
||||
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePlanOutput struct {
|
||||
Plan thoughttypes.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
type GetPlanInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"plan id"`
|
||||
}
|
||||
|
||||
type GetPlanOutput struct {
|
||||
Plan thoughttypes.PlanDetail `json:"plan"`
|
||||
}
|
||||
|
||||
type UpdatePlanInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"plan id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
|
||||
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
|
||||
Owner *string `json:"owner,omitempty" jsonschema:"empty string clears the owner"`
|
||||
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
|
||||
ClearDueDate bool `json:"clear_due_date,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
|
||||
ClearCompletedAt bool `json:"clear_completed_at,omitempty"`
|
||||
ReviewedBy *string `json:"reviewed_by,omitempty" jsonschema:"empty string clears the reviewer"`
|
||||
MarkReviewed bool `json:"mark_reviewed,omitempty" jsonschema:"set last_reviewed_at to now"`
|
||||
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
|
||||
ClearSupersedesPlanID bool `json:"clear_supersedes_plan_id,omitempty"`
|
||||
Tags *[]string `json:"tags,omitempty" jsonschema:"replaces all tags when provided; pass [] to clear"`
|
||||
}
|
||||
|
||||
type UpdatePlanOutput struct {
|
||||
Plan thoughttypes.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
type DeletePlanInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"plan id"`
|
||||
}
|
||||
|
||||
type DeletePlanOutput struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type ListPlansInput struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type ListPlansOutput struct {
|
||||
Plans []thoughttypes.Plan `json:"plans"`
|
||||
}
|
||||
|
||||
type PlanDependencyInput struct {
|
||||
PlanID uuid.UUID `json:"plan_id" jsonschema:"the plan that depends on another"`
|
||||
DependsOnPlanID uuid.UUID `json:"depends_on_plan_id" jsonschema:"the plan that must complete first"`
|
||||
}
|
||||
|
||||
type PlanRelatedInput struct {
|
||||
PlanAID uuid.UUID `json:"plan_a_id"`
|
||||
PlanBID uuid.UUID `json:"plan_b_id"`
|
||||
}
|
||||
|
||||
type PlanLinkOutput struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type PlanSkillInput struct {
|
||||
PlanID uuid.UUID `json:"plan_id"`
|
||||
SkillID uuid.UUID `json:"skill_id"`
|
||||
}
|
||||
|
||||
type ListPlanSkillsInput struct {
|
||||
PlanID uuid.UUID `json:"plan_id"`
|
||||
}
|
||||
|
||||
type ListPlanSkillsOutput struct {
|
||||
Skills []thoughttypes.AgentSkill `json:"skills"`
|
||||
}
|
||||
|
||||
type PlanGuardrailInput struct {
|
||||
PlanID uuid.UUID `json:"plan_id"`
|
||||
GuardrailID uuid.UUID `json:"guardrail_id"`
|
||||
}
|
||||
|
||||
type ListPlanGuardrailsInput struct {
|
||||
PlanID uuid.UUID `json:"plan_id"`
|
||||
}
|
||||
|
||||
type ListPlanGuardrailsOutput struct {
|
||||
Guardrails []thoughttypes.AgentGuardrail `json:"guardrails"`
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func (t *PlansTool) Create(ctx context.Context, req *mcp.CallToolRequest, in CreatePlanInput) (*mcp.CallToolResult, CreatePlanOutput, error) {
|
||||
title := strings.TrimSpace(in.Title)
|
||||
if title == "" {
|
||||
return nil, CreatePlanOutput{}, errRequiredField("title")
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, CreatePlanOutput{}, err
|
||||
}
|
||||
|
||||
plan := thoughttypes.Plan{
|
||||
Title: title,
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
Status: thoughttypes.PlanStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.PlanStatusDraft))),
|
||||
Priority: thoughttypes.PlanPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.PlanPriorityMedium))),
|
||||
Owner: strings.TrimSpace(in.Owner),
|
||||
SupersedesPlanID: in.SupersedesPlanID,
|
||||
Tags: normalizeStringSlice(in.Tags),
|
||||
}
|
||||
if project != nil {
|
||||
plan.ProjectID = &project.ID
|
||||
}
|
||||
if v := strings.TrimSpace(in.DueDate); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return nil, CreatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
|
||||
}
|
||||
plan.DueDate = &t
|
||||
}
|
||||
|
||||
created, err := t.store.CreatePlan(ctx, plan)
|
||||
if err != nil {
|
||||
return nil, CreatePlanOutput{}, err
|
||||
}
|
||||
return nil, CreatePlanOutput{Plan: created}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetPlanInput) (*mcp.CallToolResult, GetPlanOutput, error) {
|
||||
detail, err := t.store.GetPlanDetail(ctx, in.ID)
|
||||
if err != nil {
|
||||
return nil, GetPlanOutput{}, err
|
||||
}
|
||||
return nil, GetPlanOutput{Plan: detail}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) Update(ctx context.Context, _ *mcp.CallToolRequest, in UpdatePlanInput) (*mcp.CallToolResult, UpdatePlanOutput, error) {
|
||||
u := thoughttypes.PlanUpdate{
|
||||
ReviewedBy: in.ReviewedBy,
|
||||
MarkReviewed: in.MarkReviewed,
|
||||
ClearDueDate: in.ClearDueDate,
|
||||
ClearCompletedAt: in.ClearCompletedAt,
|
||||
ClearSupersedesPlanID: in.ClearSupersedesPlanID,
|
||||
SupersedesPlanID: in.SupersedesPlanID,
|
||||
Tags: in.Tags,
|
||||
Owner: in.Owner,
|
||||
}
|
||||
if v := strings.TrimSpace(in.Title); v != "" {
|
||||
u.Title = &v
|
||||
}
|
||||
if v := strings.TrimSpace(in.Description); v != "" {
|
||||
u.Description = &v
|
||||
}
|
||||
if v := strings.TrimSpace(in.Status); v != "" {
|
||||
u.Status = &v
|
||||
}
|
||||
if v := strings.TrimSpace(in.Priority); v != "" {
|
||||
u.Priority = &v
|
||||
}
|
||||
if v := strings.TrimSpace(in.DueDate); v != "" && !in.ClearDueDate {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return nil, UpdatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
|
||||
}
|
||||
u.DueDate = &parsed
|
||||
}
|
||||
if v := strings.TrimSpace(in.CompletedAt); v != "" && !in.ClearCompletedAt {
|
||||
parsed, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return nil, UpdatePlanOutput{}, errInvalidField("completed_at", "invalid completed_at", "use RFC3339 format")
|
||||
}
|
||||
u.CompletedAt = &parsed
|
||||
}
|
||||
|
||||
plan, err := t.store.UpdatePlan(ctx, in.ID, u)
|
||||
if err != nil {
|
||||
return nil, UpdatePlanOutput{}, err
|
||||
}
|
||||
return nil, UpdatePlanOutput{Plan: plan}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) Delete(ctx context.Context, _ *mcp.CallToolRequest, in DeletePlanInput) (*mcp.CallToolResult, DeletePlanOutput, error) {
|
||||
if err := t.store.DeletePlan(ctx, in.ID); err != nil {
|
||||
return nil, DeletePlanOutput{}, err
|
||||
}
|
||||
return nil, DeletePlanOutput{Deleted: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListPlansInput) (*mcp.CallToolResult, ListPlansOutput, error) {
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, ListPlansOutput{}, err
|
||||
}
|
||||
|
||||
filter := thoughttypes.PlanFilter{
|
||||
Limit: normalizeLimit(in.Limit, t.cfg),
|
||||
Status: strings.TrimSpace(in.Status),
|
||||
Priority: strings.TrimSpace(in.Priority),
|
||||
Owner: strings.TrimSpace(in.Owner),
|
||||
Tag: strings.TrimSpace(in.Tag),
|
||||
Query: strings.TrimSpace(in.Query),
|
||||
}
|
||||
if project != nil {
|
||||
filter.ProjectID = &project.ID
|
||||
}
|
||||
|
||||
plans, err := t.store.ListPlans(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, ListPlansOutput{}, err
|
||||
}
|
||||
return nil, ListPlansOutput{Plans: plans}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) AddDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if in.PlanID == in.DependsOnPlanID {
|
||||
return nil, PlanLinkOutput{}, errInvalidField("depends_on_plan_id", "a plan cannot depend on itself", "use a different plan id")
|
||||
}
|
||||
if err := t.store.AddPlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) RemoveDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.RemovePlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) AddRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if in.PlanAID == in.PlanBID {
|
||||
return nil, PlanLinkOutput{}, errInvalidField("plan_b_id", "a plan cannot be related to itself", "use a different plan id")
|
||||
}
|
||||
if err := t.store.AddRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) RemoveRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.RemoveRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.AddPlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.RemovePlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanSkillsInput) (*mcp.CallToolResult, ListPlanSkillsOutput, error) {
|
||||
skills, err := t.store.ListPlanSkills(ctx, in.PlanID)
|
||||
if err != nil {
|
||||
return nil, ListPlanSkillsOutput{}, err
|
||||
}
|
||||
if skills == nil {
|
||||
skills = []thoughttypes.AgentSkill{}
|
||||
}
|
||||
return nil, ListPlanSkillsOutput{Skills: skills}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.AddPlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
|
||||
if err := t.store.RemovePlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
|
||||
return nil, PlanLinkOutput{}, err
|
||||
}
|
||||
return nil, PlanLinkOutput{OK: true}, nil
|
||||
}
|
||||
|
||||
func (t *PlansTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanGuardrailsInput) (*mcp.CallToolResult, ListPlanGuardrailsOutput, error) {
|
||||
guardrails, err := t.store.ListPlanGuardrails(ctx, in.PlanID)
|
||||
if err != nil {
|
||||
return nil, ListPlanGuardrailsOutput{}, err
|
||||
}
|
||||
if guardrails == nil {
|
||||
guardrails = []thoughttypes.AgentGuardrail{}
|
||||
}
|
||||
return nil, ListPlanGuardrailsOutput{Guardrails: guardrails}, nil
|
||||
}
|
||||
83
internal/types/plan.go
Normal file
83
internal/types/plan.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PlanStatus string
|
||||
|
||||
const (
|
||||
PlanStatusDraft PlanStatus = "draft"
|
||||
PlanStatusActive PlanStatus = "active"
|
||||
PlanStatusBlocked PlanStatus = "blocked"
|
||||
PlanStatusCompleted PlanStatus = "completed"
|
||||
PlanStatusCancelled PlanStatus = "cancelled"
|
||||
PlanStatusSuperseded PlanStatus = "superseded"
|
||||
)
|
||||
|
||||
type PlanPriority string
|
||||
|
||||
const (
|
||||
PlanPriorityLow PlanPriority = "low"
|
||||
PlanPriorityMedium PlanPriority = "medium"
|
||||
PlanPriorityHigh PlanPriority = "high"
|
||||
PlanPriorityCritical PlanPriority = "critical"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status PlanStatus `json:"status"`
|
||||
Priority PlanPriority `json:"priority"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
DueDate *time.Time `json:"due_date,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
ReviewedBy string `json:"reviewed_by,omitempty"`
|
||||
LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"`
|
||||
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PlanDetail enriches Plan with all related records, returned by get_plan.
|
||||
type PlanDetail struct {
|
||||
Plan
|
||||
DependsOn []Plan `json:"depends_on"`
|
||||
Blocks []Plan `json:"blocks"`
|
||||
RelatedPlans []Plan `json:"related_plans"`
|
||||
Skills []AgentSkill `json:"skills"`
|
||||
Guardrails []AgentGuardrail `json:"guardrails"`
|
||||
}
|
||||
|
||||
type PlanFilter struct {
|
||||
Limit int
|
||||
ProjectID *uuid.UUID
|
||||
Status string
|
||||
Priority string
|
||||
Owner string
|
||||
Tag string
|
||||
Query string
|
||||
}
|
||||
|
||||
// PlanUpdate describes a partial update; nil pointer fields are not touched.
|
||||
type PlanUpdate struct {
|
||||
Title *string
|
||||
Description *string
|
||||
Status *string
|
||||
Priority *string
|
||||
Owner *string // "" to clear
|
||||
DueDate *time.Time // nil = no change
|
||||
ClearDueDate bool // true = set NULL (takes priority over DueDate)
|
||||
CompletedAt *time.Time
|
||||
ClearCompletedAt bool
|
||||
ReviewedBy *string // "" to clear
|
||||
MarkReviewed bool // sets last_reviewed_at = now()
|
||||
SupersedesPlanID *uuid.UUID
|
||||
ClearSupersedesPlanID bool
|
||||
Tags *[]string // nil = no change; replace when non-nil
|
||||
}
|
||||
@@ -89,6 +89,32 @@ Do not abandon the project scope or retry without a project. The project simply
|
||||
- Do not base64-encode a file to pass it to `save_file` if an `amcs://files/{id}` URI is already available from a prior `upload_file` or HTTP upload.
|
||||
- When saving, choose the narrowest correct scope: project if project-specific, global if not.
|
||||
|
||||
## Plans
|
||||
|
||||
Plans are structured, trackable work items linked to projects. Use plans for multi-step goals, workstreams, or anything that needs an owner, due date, status lifecycle, or explicit dependency tracking.
|
||||
|
||||
- **Status lifecycle**: `draft` → `active` → `blocked` | `completed` | `cancelled` | `superseded`
|
||||
- **Priority**: `low`, `medium` (default), `high`, `critical`
|
||||
- Create plans with `create_plan` (required: `title`; optional: `description`, `status`, `priority`, `project`, `owner`, `due_date`, `supersedes_plan_id`, `tags`).
|
||||
- Retrieve a full plan with `get_plan` — returns the plan plus `depends_on`, `blocks`, `related_plans`, `skills`, and `guardrails` in a single call.
|
||||
- Partially update a plan with `update_plan` (only provided fields change). Use `mark_reviewed: true` to stamp `last_reviewed_at` without manually passing a timestamp.
|
||||
- List and filter with `list_plans` (project/status/priority/owner/tag/query).
|
||||
- Delete permanently with `delete_plan`.
|
||||
|
||||
**Dependencies** (directional — "A cannot proceed until B is done"):
|
||||
- `add_plan_dependency` / `remove_plan_dependency` using `plan_id` and `depends_on_plan_id`.
|
||||
- `get_plan` returns `depends_on` (plans this plan waits on) and `blocks` (plans waiting on this one).
|
||||
|
||||
**Related plans** (bidirectional — thematically linked, no ordering):
|
||||
- `add_related_plan` / `remove_related_plan` using `plan_a_id` and `plan_b_id` (order does not matter).
|
||||
|
||||
**Plan skills and guardrails** (agent behaviour scoped to a plan):
|
||||
- `add_plan_skill` / `remove_plan_skill` / `list_plan_skills`
|
||||
- `add_plan_guardrail` / `remove_plan_guardrail` / `list_plan_guardrails`
|
||||
- Load plan skills and guardrails alongside project skills/guardrails when working within a specific plan's scope.
|
||||
|
||||
**Freshness**: use `last_reviewed_at` and `reviewed_by` to track whether a plan is current. Set `mark_reviewed: true` on `update_plan` after reviewing a plan so staleness is visible in `list_plans` results.
|
||||
|
||||
## Tool Annotations
|
||||
|
||||
As you learn non-obvious behaviours, gotchas, or workflow patterns for individual tools, persist them with `annotate_tool`:
|
||||
@@ -109,4 +135,4 @@ Notes are returned by `describe_tools` in future sessions. Annotate whenever you
|
||||
|
||||
## Short Operational Form
|
||||
|
||||
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, and store curated durable lessons with `add_learning`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
|
||||
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, store curated durable lessons with `add_learning`, and track structured multi-step goals with `create_plan`. Use `get_plan` to load a plan's full context including dependencies, related plans, and linked skills/guardrails. Stamp `last_reviewed_at` on plans you review with `update_plan mark_reviewed: true`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
92
schema/plans.dbml
Normal file
92
schema/plans.dbml
Normal file
@@ -0,0 +1,92 @@
|
||||
Table plans {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
title text [not null]
|
||||
description text [not null, default: '']
|
||||
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
||||
priority text [not null, default: 'medium'] // low, medium, high, critical
|
||||
project_id uuid [ref: > projects.guid]
|
||||
owner text
|
||||
due_date timestamptz
|
||||
completed_at timestamptz
|
||||
reviewed_by text
|
||||
last_reviewed_at timestamptz
|
||||
supersedes_plan_id uuid [ref: > plans.id]
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
project_id
|
||||
status
|
||||
priority
|
||||
owner
|
||||
due_date
|
||||
last_reviewed_at
|
||||
tags [type: gin]
|
||||
title [type: gin]
|
||||
}
|
||||
}
|
||||
|
||||
// Directional: plan_id cannot proceed until depends_on_plan_id is complete
|
||||
Table plan_dependencies {
|
||||
id serial [pk]
|
||||
plan_id uuid [not null, ref: > plans.id]
|
||||
depends_on_plan_id uuid [not null, ref: > plans.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(plan_id, depends_on_plan_id) [unique]
|
||||
plan_id
|
||||
depends_on_plan_id
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional: store with plan_a_id < plan_b_id to avoid duplicates
|
||||
Table plan_related_plans {
|
||||
id serial [pk]
|
||||
plan_a_id uuid [not null, ref: > plans.id]
|
||||
plan_b_id uuid [not null, ref: > plans.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(plan_a_id, plan_b_id) [unique]
|
||||
plan_a_id
|
||||
plan_b_id
|
||||
}
|
||||
}
|
||||
|
||||
Table plan_skills {
|
||||
id serial [pk]
|
||||
plan_id uuid [not null, ref: > plans.id]
|
||||
skill_id uuid [not null, ref: > agent_skills.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(plan_id, skill_id) [unique]
|
||||
plan_id
|
||||
}
|
||||
}
|
||||
|
||||
Table plan_guardrails {
|
||||
id serial [pk]
|
||||
plan_id uuid [not null, ref: > plans.id]
|
||||
guardrail_id uuid [not null, ref: > agent_guardrails.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(plan_id, guardrail_id) [unique]
|
||||
plan_id
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-file refs (for relspecgo merge)
|
||||
Ref: plans.project_id > projects.guid [delete: set null]
|
||||
Ref: plans.supersedes_plan_id > plans.id [delete: set null]
|
||||
Ref: plan_dependencies.plan_id > plans.id [delete: cascade]
|
||||
Ref: plan_dependencies.depends_on_plan_id > plans.id [delete: cascade]
|
||||
Ref: plan_related_plans.plan_a_id > plans.id [delete: cascade]
|
||||
Ref: plan_related_plans.plan_b_id > plans.id [delete: cascade]
|
||||
Ref: plan_skills.plan_id > plans.id [delete: cascade]
|
||||
Ref: plan_skills.skill_id > agent_skills.id [delete: cascade]
|
||||
Ref: plan_guardrails.plan_id > plans.id [delete: cascade]
|
||||
Ref: plan_guardrails.guardrail_id > agent_guardrails.id [delete: cascade]
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -9,7 +9,7 @@
|
||||
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-slate-950">
|
||||
<body class="bg-slate-950" data-theme="amcs">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.50.0",
|
||||
"@sentry/svelte": "^10.51.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.39"
|
||||
"@warkypublic/svelix": "^0.1.40"
|
||||
}
|
||||
}
|
||||
82
ui/pnpm-lock.yaml
generated
82
ui/pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@sentry/svelte':
|
||||
specifier: ^10.50.0
|
||||
version: 10.50.0(svelte@5.55.5)
|
||||
specifier: ^10.51.0
|
||||
version: 10.51.0(svelte@5.55.5)
|
||||
'@skeletonlabs/skeleton':
|
||||
specifier: ^4.15.2
|
||||
version: 4.15.2(tailwindcss@4.2.4)
|
||||
@@ -27,8 +27,8 @@ importers:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@warkypublic/svelix':
|
||||
specifier: ^0.1.39
|
||||
version: 0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
|
||||
specifier: ^0.1.40
|
||||
version: 0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)
|
||||
devDependencies:
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^7.0.0
|
||||
@@ -315,32 +315,32 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.17':
|
||||
resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==}
|
||||
|
||||
'@sentry-internal/browser-utils@10.50.0':
|
||||
resolution: {integrity: sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==}
|
||||
'@sentry-internal/browser-utils@10.51.0':
|
||||
resolution: {integrity: sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@10.50.0':
|
||||
resolution: {integrity: sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==}
|
||||
'@sentry-internal/feedback@10.51.0':
|
||||
resolution: {integrity: sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@10.50.0':
|
||||
resolution: {integrity: sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==}
|
||||
'@sentry-internal/replay-canvas@10.51.0':
|
||||
resolution: {integrity: sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@10.50.0':
|
||||
resolution: {integrity: sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==}
|
||||
'@sentry-internal/replay@10.51.0':
|
||||
resolution: {integrity: sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@10.50.0':
|
||||
resolution: {integrity: sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==}
|
||||
'@sentry/browser@10.51.0':
|
||||
resolution: {integrity: sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@10.50.0':
|
||||
resolution: {integrity: sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==}
|
||||
'@sentry/core@10.51.0':
|
||||
resolution: {integrity: sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/svelte@10.50.0':
|
||||
resolution: {integrity: sha512-pkd9HNpZN+8x9i8n24fpV+Q3/sKDkBKyJ29iNzbhGnZ3CeRPwKxwQOoiBBPQkllYWzr/a7cYFtBKNLdpmTFCOg==}
|
||||
'@sentry/svelte@10.51.0':
|
||||
resolution: {integrity: sha512-2/OwIs+WXk+H/CAnWeiQMvXx5EG8LxCtqu4m+HdHtJTUQtERC388zNShZTJU9YYR71KoDOVAU0Q6B2sKNcssFA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
svelte: 3.x || 4.x || 5.x
|
||||
@@ -758,8 +758,8 @@ packages:
|
||||
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@warkypublic/svelix@0.1.39':
|
||||
resolution: {integrity: sha512-CeKOyabAXTt5MXzRkQyG0G7+1wwgYD/e4+fX9gRsQWxlVVrHv8qdbK1HxHHKMmu4ZW9csf0dXWcEIt/TSM9qAg==}
|
||||
'@warkypublic/svelix@0.1.40':
|
||||
resolution: {integrity: sha512-Pn4T+VVI1Pfrtkbr61oqVyhaUTySU0r6gti9SeSqL+ZJeRfXAj+ZTuwlHzk0w9BxrwlWDnVinGyrpbSjnwb+iQ==}
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
|
||||
@@ -2062,38 +2062,38 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.17': {}
|
||||
|
||||
'@sentry-internal/browser-utils@10.50.0':
|
||||
'@sentry-internal/browser-utils@10.51.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/feedback@10.50.0':
|
||||
'@sentry-internal/feedback@10.51.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/replay-canvas@10.50.0':
|
||||
'@sentry-internal/replay-canvas@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/replay': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry-internal/replay@10.50.0':
|
||||
'@sentry-internal/replay@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/browser-utils': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry/browser@10.50.0':
|
||||
'@sentry/browser@10.51.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.50.0
|
||||
'@sentry-internal/feedback': 10.50.0
|
||||
'@sentry-internal/replay': 10.50.0
|
||||
'@sentry-internal/replay-canvas': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry-internal/browser-utils': 10.51.0
|
||||
'@sentry-internal/feedback': 10.51.0
|
||||
'@sentry-internal/replay': 10.51.0
|
||||
'@sentry-internal/replay-canvas': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
|
||||
'@sentry/core@10.50.0': {}
|
||||
'@sentry/core@10.51.0': {}
|
||||
|
||||
'@sentry/svelte@10.50.0(svelte@5.55.5)':
|
||||
'@sentry/svelte@10.51.0(svelte@5.55.5)':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.50.0
|
||||
'@sentry/core': 10.50.0
|
||||
'@sentry/browser': 10.51.0
|
||||
'@sentry/core': 10.51.0
|
||||
magic-string: 0.30.21
|
||||
svelte: 5.55.5
|
||||
|
||||
@@ -2556,7 +2556,7 @@ snapshots:
|
||||
dependencies:
|
||||
uuid: 13.0.0
|
||||
|
||||
'@warkypublic/svelix@0.1.39(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
|
||||
'@warkypublic/svelix@0.1.40(highlight.js@11.8.0)(svelte@5.55.5)(unified@11.0.5)':
|
||||
dependencies:
|
||||
'@cartamd/plugin-anchor': 2.2.0(carta-md@4.11.2(svelte@5.55.5))
|
||||
'@cartamd/plugin-attachment': 4.2.0(carta-md@4.11.2(svelte@5.55.5))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import LoginPage from './components/auth/LoginPage.svelte';
|
||||
import AdminShell from './components/shell/AdminShell.svelte';
|
||||
import type { ShellPage, StatusResponse } from './types';
|
||||
import type { PublicStatusResponse, ShellPage, StatusResponse } from './types';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import {
|
||||
ensureApiURL,
|
||||
@@ -20,6 +20,9 @@
|
||||
let data = $state<StatusResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let publicStatus = $state<PublicStatusResponse | null>(null);
|
||||
let publicStatusLoading = $state(false);
|
||||
let publicStatusError = $state('');
|
||||
let currentPage = $state<ShellPage>('dashboard');
|
||||
|
||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||
@@ -57,7 +60,7 @@
|
||||
const token = await loginWithCredentials(username, password);
|
||||
await GlobalStateStore.getState().login(token, { username });
|
||||
authMessage = 'Login successful.';
|
||||
await loadStatus();
|
||||
await loadDashboardStatus();
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'Login failed.';
|
||||
} finally {
|
||||
@@ -88,7 +91,7 @@
|
||||
|
||||
authMessage = 'OAuth login complete. Welcome back.';
|
||||
window.history.replaceState({}, '', '/');
|
||||
await loadStatus();
|
||||
await loadDashboardStatus();
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
||||
} finally {
|
||||
@@ -100,14 +103,23 @@
|
||||
await GlobalStateStore.getState().logout();
|
||||
authMessage = 'Logged out.';
|
||||
authError = '';
|
||||
await loadPublicStatus();
|
||||
}
|
||||
|
||||
async function loadStatus(): Promise<void> {
|
||||
async function loadDashboardStatus(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const token = GlobalStateStore.getState().session?.authToken;
|
||||
if (!token) {
|
||||
throw new Error('Missing auth token for dashboard status.');
|
||||
}
|
||||
const response = await fetch('/api/status', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`);
|
||||
}
|
||||
@@ -141,6 +153,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPublicStatus(): Promise<void> {
|
||||
publicStatusLoading = true;
|
||||
publicStatusError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/status');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Public status request failed with ${response.status}`);
|
||||
}
|
||||
const raw = (await response.json()) as Partial<PublicStatusResponse> | null;
|
||||
publicStatus = {
|
||||
connected_count: raw?.connected_count ?? 0,
|
||||
connected_window: raw?.connected_window ?? 'last 10 minutes',
|
||||
entries: Array.isArray(raw?.entries) ? raw.entries : []
|
||||
};
|
||||
} catch (err) {
|
||||
publicStatusError = err instanceof Error ? err.message : 'Failed to load public status';
|
||||
} finally {
|
||||
publicStatusLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setCurrentPath(window.location.pathname);
|
||||
@@ -153,7 +187,11 @@
|
||||
|
||||
await GlobalStateStore.getState().fetchData();
|
||||
|
||||
await loadStatus();
|
||||
if (GlobalStateStore.getState().isLoggedIn()) {
|
||||
await loadDashboardStatus();
|
||||
return;
|
||||
}
|
||||
await loadPublicStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -161,7 +199,7 @@
|
||||
<title>AMCS Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div data-theme="amcs" class="min-h-screen bg-slate-950 text-slate-100">
|
||||
{#if !isLoggedIn.current}
|
||||
<LoginPage
|
||||
{isOAuthCallback}
|
||||
@@ -169,9 +207,9 @@
|
||||
{authBusy}
|
||||
{authError}
|
||||
{authMessage}
|
||||
statusData={data}
|
||||
statusLoading={loading}
|
||||
statusError={error}
|
||||
statusData={publicStatus}
|
||||
statusLoading={publicStatusLoading}
|
||||
statusError={publicStatusError}
|
||||
onlogin={handleCredentialLogin}
|
||||
/>
|
||||
{:else}
|
||||
@@ -182,7 +220,7 @@
|
||||
{error}
|
||||
onlogout={logout}
|
||||
onnavigate={(page) => { currentPage = page; }}
|
||||
onrefresh={loadStatus}
|
||||
onrefresh={loadDashboardStatus}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
218
ui/src/amcs.theme.css
Normal file
218
ui/src/amcs.theme.css
Normal file
@@ -0,0 +1,218 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
[data-theme='amcs'] {
|
||||
--text-scaling: 1;
|
||||
--base-font-color: var(--color-surface-950);
|
||||
--base-font-color-dark: var(--color-surface-50);
|
||||
--base-font-family: 'Inter', system-ui, sans-serif;
|
||||
--base-font-size: inherit;
|
||||
--base-line-height: 1.5;
|
||||
--base-font-weight: 400;
|
||||
--base-font-style: normal;
|
||||
--base-letter-spacing: 0;
|
||||
--heading-font-color: inherit;
|
||||
--heading-font-color-dark: inherit;
|
||||
--heading-font-family: 'Inter', system-ui, sans-serif;
|
||||
--heading-font-weight: 600;
|
||||
--heading-font-style: normal;
|
||||
--heading-letter-spacing: -0.01em;
|
||||
--anchor-font-color: var(--color-primary-400);
|
||||
--anchor-font-color-dark: var(--color-primary-300);
|
||||
--anchor-font-family: inherit;
|
||||
--anchor-font-size: inherit;
|
||||
--anchor-line-height: inherit;
|
||||
--anchor-font-weight: inherit;
|
||||
--anchor-font-style: inherit;
|
||||
--anchor-letter-spacing: inherit;
|
||||
--anchor-text-decoration: none;
|
||||
--anchor-text-decoration-hover: underline;
|
||||
--anchor-text-decoration-active: none;
|
||||
--anchor-text-decoration-focus: none;
|
||||
--spacing: 0.25rem;
|
||||
--radius-base: 0.75rem;
|
||||
--radius-container: 1rem;
|
||||
--default-border-width: 1px;
|
||||
--default-divide-width: 1px;
|
||||
--default-ring-width: 1px;
|
||||
--body-background-color: var(--color-surface-50);
|
||||
--body-background-color-dark: var(--color-surface-950);
|
||||
|
||||
/* Primary: AMCS cyan */
|
||||
--color-primary-50: oklch(98.4% 0.019 200.87deg);
|
||||
--color-primary-100: oklch(95.6% 0.045 203.39deg);
|
||||
--color-primary-200: oklch(91.7% 0.08 205.04deg);
|
||||
--color-primary-300: oklch(86.5% 0.127 207.08deg);
|
||||
--color-primary-400: oklch(78.9% 0.154 211.53deg);
|
||||
--color-primary-500: oklch(71.5% 0.143 215.22deg);
|
||||
--color-primary-600: oklch(60.9% 0.126 221.72deg);
|
||||
--color-primary-700: oklch(51.9% 0.105 223.13deg);
|
||||
--color-primary-800: oklch(45% 0.085 224.28deg);
|
||||
--color-primary-900: oklch(39.8% 0.07 227.39deg);
|
||||
--color-primary-950: oklch(30.2% 0.056 229.7deg);
|
||||
--color-primary-contrast-dark: var(--color-primary-950);
|
||||
--color-primary-contrast-light: var(--color-primary-50);
|
||||
--color-primary-contrast-50: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-100: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-200: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-300: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-400: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-500: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-600: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-700: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-800: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-900: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-950: var(--color-primary-contrast-light);
|
||||
|
||||
/* Secondary: deeper steel blue */
|
||||
--color-secondary-50: oklch(97.7% 0.006 247.9deg);
|
||||
--color-secondary-100: oklch(95.2% 0.011 250.1deg);
|
||||
--color-secondary-200: oklch(90.7% 0.021 252.8deg);
|
||||
--color-secondary-300: oklch(83.8% 0.036 254.6deg);
|
||||
--color-secondary-400: oklch(70.4% 0.05 256.7deg);
|
||||
--color-secondary-500: oklch(55.4% 0.046 257.4deg);
|
||||
--color-secondary-600: oklch(46.1% 0.043 257.3deg);
|
||||
--color-secondary-700: oklch(38.7% 0.044 257.3deg);
|
||||
--color-secondary-800: oklch(30.5% 0.043 260deg);
|
||||
--color-secondary-900: oklch(24.6% 0.042 264deg);
|
||||
--color-secondary-950: oklch(17.8% 0.041 265deg);
|
||||
--color-secondary-contrast-dark: var(--color-secondary-950);
|
||||
--color-secondary-contrast-light: var(--color-secondary-50);
|
||||
--color-secondary-contrast-50: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-100: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-400: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-500: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
|
||||
|
||||
/* Tertiary: bright highlight cyan */
|
||||
--color-tertiary-50: oklch(98.5% 0.02 214deg);
|
||||
--color-tertiary-100: oklch(96.2% 0.05 214deg);
|
||||
--color-tertiary-200: oklch(92.4% 0.088 213deg);
|
||||
--color-tertiary-300: oklch(87.1% 0.132 212deg);
|
||||
--color-tertiary-400: oklch(80.2% 0.157 211deg);
|
||||
--color-tertiary-500: oklch(73.8% 0.145 210deg);
|
||||
--color-tertiary-600: oklch(64.2% 0.126 214deg);
|
||||
--color-tertiary-700: oklch(55.2% 0.107 218deg);
|
||||
--color-tertiary-800: oklch(46.1% 0.087 221deg);
|
||||
--color-tertiary-900: oklch(38.7% 0.07 225deg);
|
||||
--color-tertiary-950: oklch(29.1% 0.053 229deg);
|
||||
--color-tertiary-contrast-dark: var(--color-tertiary-950);
|
||||
--color-tertiary-contrast-light: var(--color-tertiary-50);
|
||||
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-400: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-500: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-600: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
|
||||
|
||||
--color-success-50: oklch(97.9% 0.021 166deg);
|
||||
--color-success-100: oklch(95.1% 0.05 167deg);
|
||||
--color-success-200: oklch(90.5% 0.09 166deg);
|
||||
--color-success-300: oklch(84.2% 0.137 165deg);
|
||||
--color-success-400: oklch(76.5% 0.146 164deg);
|
||||
--color-success-500: oklch(69.6% 0.135 163deg);
|
||||
--color-success-600: oklch(59.6% 0.115 163deg);
|
||||
--color-success-700: oklch(50.8% 0.096 164deg);
|
||||
--color-success-800: oklch(42.7% 0.077 165deg);
|
||||
--color-success-900: oklch(35.4% 0.061 166deg);
|
||||
--color-success-950: oklch(24.5% 0.041 168deg);
|
||||
--color-success-contrast-dark: var(--color-success-950);
|
||||
--color-success-contrast-light: var(--color-success-50);
|
||||
--color-success-contrast-50: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-100: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-200: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-300: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-400: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-500: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-600: var(--color-success-contrast-light);
|
||||
--color-success-contrast-700: var(--color-success-contrast-light);
|
||||
--color-success-contrast-800: var(--color-success-contrast-light);
|
||||
--color-success-contrast-900: var(--color-success-contrast-light);
|
||||
--color-success-contrast-950: var(--color-success-contrast-light);
|
||||
|
||||
--color-warning-50: oklch(98.7% 0.022 95deg);
|
||||
--color-warning-100: oklch(96.2% 0.05 93deg);
|
||||
--color-warning-200: oklch(92.5% 0.095 90deg);
|
||||
--color-warning-300: oklch(87.9% 0.145 86deg);
|
||||
--color-warning-400: oklch(82.8% 0.168 81deg);
|
||||
--color-warning-500: oklch(76.9% 0.164 74deg);
|
||||
--color-warning-600: oklch(66.6% 0.149 66deg);
|
||||
--color-warning-700: oklch(56.3% 0.13 60deg);
|
||||
--color-warning-800: oklch(47.8% 0.111 56deg);
|
||||
--color-warning-900: oklch(40.5% 0.094 53deg);
|
||||
--color-warning-950: oklch(28.2% 0.066 49deg);
|
||||
--color-warning-contrast-dark: var(--color-warning-950);
|
||||
--color-warning-contrast-light: var(--color-warning-50);
|
||||
--color-warning-contrast-50: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-100: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-200: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-300: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-400: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-500: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-600: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-700: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-800: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-900: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-950: var(--color-warning-contrast-light);
|
||||
|
||||
--color-error-50: oklch(97.1% 0.014 17deg);
|
||||
--color-error-100: oklch(93.7% 0.032 18deg);
|
||||
--color-error-200: oklch(88.5% 0.062 19deg);
|
||||
--color-error-300: oklch(81.4% 0.104 21deg);
|
||||
--color-error-400: oklch(71.2% 0.164 24deg);
|
||||
--color-error-500: oklch(63.7% 0.208 25deg);
|
||||
--color-error-600: oklch(57.7% 0.204 26deg);
|
||||
--color-error-700: oklch(50.5% 0.182 27deg);
|
||||
--color-error-800: oklch(44.4% 0.156 27deg);
|
||||
--color-error-900: oklch(39.6% 0.129 28deg);
|
||||
--color-error-950: oklch(25.8% 0.082 28deg);
|
||||
--color-error-contrast-dark: var(--color-error-950);
|
||||
--color-error-contrast-light: var(--color-error-50);
|
||||
--color-error-contrast-50: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-100: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-200: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-300: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-400: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-500: var(--color-error-contrast-light);
|
||||
--color-error-contrast-600: var(--color-error-contrast-light);
|
||||
--color-error-contrast-700: var(--color-error-contrast-light);
|
||||
--color-error-contrast-800: var(--color-error-contrast-light);
|
||||
--color-error-contrast-900: var(--color-error-contrast-light);
|
||||
--color-error-contrast-950: var(--color-error-contrast-light);
|
||||
|
||||
/* Surface: slate-based AMCS dark shell */
|
||||
--color-surface-50: oklch(98.4% 0.003 247.86deg);
|
||||
--color-surface-100: oklch(96.8% 0.007 247.9deg);
|
||||
--color-surface-200: oklch(92.9% 0.013 255.51deg);
|
||||
--color-surface-300: oklch(86.9% 0.022 252.89deg);
|
||||
--color-surface-400: oklch(70.4% 0.04 256.79deg);
|
||||
--color-surface-500: oklch(55.4% 0.046 257.42deg);
|
||||
--color-surface-600: oklch(44.6% 0.043 257.28deg);
|
||||
--color-surface-700: oklch(37.2% 0.044 257.29deg);
|
||||
--color-surface-800: oklch(27.9% 0.041 260.03deg);
|
||||
--color-surface-900: oklch(20.8% 0.042 265.76deg);
|
||||
--color-surface-950: oklch(12.9% 0.042 264.7deg);
|
||||
--color-surface-contrast-dark: var(--color-surface-950);
|
||||
--color-surface-contrast-light: var(--color-surface-50);
|
||||
--color-surface-contrast-50: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-100: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-200: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-300: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-400: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-500: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-600: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-700: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-800: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-900: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-950: var(--color-surface-contrast-light);
|
||||
}
|
||||
@@ -152,7 +152,10 @@ export const api = {
|
||||
create: (name: string, description: string) =>
|
||||
rsCall<import('./types').Project>('/api/rs/public/projects', 'create', {
|
||||
data: { name, description }
|
||||
})
|
||||
}),
|
||||
update: (id: string, data: { name?: string; description?: string }) =>
|
||||
rsCall<void>(`/api/rs/public/projects/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/projects/${id}`, 'delete')
|
||||
},
|
||||
thoughts: {
|
||||
list: (params: { q?: string; project_id?: string; limit?: number; offset?: number; include_archived?: boolean }) => {
|
||||
@@ -232,7 +235,11 @@ export const api = {
|
||||
archive: (id: string) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', {
|
||||
data: { archived_at: new Date().toISOString() }
|
||||
})
|
||||
}),
|
||||
create: (data: { content: string; project_id?: string }) =>
|
||||
rsCall<import('./types').Thought>('/api/rs/public/thoughts', 'create', { data }),
|
||||
update: (id: string, data: { content?: string }) =>
|
||||
rsCall<void>(`/api/rs/public/thoughts/${id}`, 'update', { data })
|
||||
},
|
||||
skills: {
|
||||
list: async (tag?: string) => {
|
||||
@@ -243,7 +250,18 @@ export const api = {
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete')
|
||||
get: async (id: string) => {
|
||||
const row = await rsCall<Omit<import('./types').AgentSkill, 'tags'> & { tags?: unknown }>(
|
||||
`/api/rs/public/agent_skills/${id}`,
|
||||
'read'
|
||||
);
|
||||
return { ...row, tags: normalizeTags(row.tags) };
|
||||
},
|
||||
create: (data: { name: string; description?: string; content: string; tags?: string[] }) =>
|
||||
rsCall<import('./types').AgentSkill>('/api/rs/public/agent_skills', 'create', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'delete'),
|
||||
update: (id: string, data: Partial<import('./types').AgentSkill>) =>
|
||||
rsCall<void>(`/api/rs/public/agent_skills/${id}`, 'update', { data })
|
||||
},
|
||||
guardrails: {
|
||||
list: async (params?: { tag?: string; severity?: string }) => {
|
||||
@@ -258,7 +276,9 @@ export const api = {
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete')
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'delete'),
|
||||
update: (id: string, data: Partial<import('./types').AgentGuardrail>) =>
|
||||
rsCall<void>(`/api/rs/public/agent_guardrails/${id}`, 'update', { data })
|
||||
},
|
||||
files: {
|
||||
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
|
||||
@@ -313,6 +333,48 @@ export const api = {
|
||||
dry_run: input?.dry_run ?? false
|
||||
})
|
||||
},
|
||||
plans: {
|
||||
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
if (params?.status) filters.push({ column: 'status', operator: 'eq', value: params.status });
|
||||
if (params?.priority) filters.push({ column: 'priority', operator: 'eq', value: params.priority });
|
||||
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
|
||||
|
||||
const rows = await rsReadMany<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>('plans', {
|
||||
filters,
|
||||
limit: params?.limit ?? 500,
|
||||
sort: [{ column: 'updated_at', direction: 'desc' }]
|
||||
});
|
||||
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
|
||||
},
|
||||
get: async (id: string) => {
|
||||
const row = await rsCall<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>(
|
||||
`/api/rs/public/plans/${id}`,
|
||||
'read'
|
||||
);
|
||||
return { ...row, tags: normalizeTags(row.tags) };
|
||||
},
|
||||
create: (data: object) =>
|
||||
rsCall<import('./types').Plan>('/api/rs/public/plans', 'create', { data }),
|
||||
update: (id: string, data: Partial<import('./types').Plan>) =>
|
||||
rsCall<void>(`/api/rs/public/plans/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/plans/${id}`, 'delete')
|
||||
},
|
||||
learnings: {
|
||||
list: (params?: { limit?: number; offset?: number }) =>
|
||||
rsReadMany<import('./types').Learning>('learnings', {
|
||||
limit: params?.limit ?? 500,
|
||||
...(params?.offset !== undefined ? { offset: params.offset } : {}),
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
}),
|
||||
get: (id: string) =>
|
||||
rsCall<import('./types').Learning>(`/api/rs/public/learnings/${id}`, 'read'),
|
||||
create: (data: object) =>
|
||||
rsCall<import('./types').Learning>('/api/rs/public/learnings', 'create', { data }),
|
||||
update: (id: string, data: Partial<import('./types').Learning>) =>
|
||||
rsCall<void>(`/api/rs/public/learnings/${id}`, 'update', { data }),
|
||||
delete: (id: string) => rsCall<void>(`/api/rs/public/learnings/${id}`, 'delete')
|
||||
},
|
||||
stats: async () => {
|
||||
type StatsThoughtRow = {
|
||||
metadata?: {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@import '@warkypublic/svelix/css/tailwind-source.css';
|
||||
@import './amcs.theme.css';
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import '@skeletonlabs/skeleton-svelte';
|
||||
@import '@skeletonlabs/skeleton/themes/cerberus';
|
||||
|
||||
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist/**/*.{js,svelte,ts}';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
@@ -14,3 +22,12 @@ body,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
.input::placeholder,
|
||||
.textarea::placeholder,
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.65;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { StatusResponse } from "../../types";
|
||||
import type { PublicStatusResponse } from "../../types";
|
||||
type IntelligenceCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -16,7 +16,7 @@
|
||||
error,
|
||||
}: {
|
||||
isOAuthCallback: boolean;
|
||||
data: StatusResponse | null;
|
||||
data: PublicStatusResponse | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
} = $props();
|
||||
@@ -143,10 +143,24 @@
|
||||
<p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p>
|
||||
<p class="mt-2 text-xs text-rose-200/80">{error}</p>
|
||||
{:else if data}
|
||||
<p class="mt-2 text-2xl font-semibold text-white">{data.version}</p>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
{data.connected_count} connected in {data.connected_window}
|
||||
<p class="mt-2 text-2xl font-semibold text-white">
|
||||
{data.connected_count}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-slate-400">
|
||||
connected in {data.connected_window}
|
||||
</p>
|
||||
{#if data.entries.length > 0}
|
||||
<p class="mt-2 text-xs text-slate-400">
|
||||
Clients:
|
||||
{#each data.entries.slice(0, 3) as client, idx}
|
||||
<code class="text-slate-200">{client.key_id}</code>{idx <
|
||||
Math.min(data.entries.length, 3) - 1
|
||||
? ", "
|
||||
: ""}
|
||||
{/each}
|
||||
{data.entries.length > 3 ? " …" : ""}
|
||||
</p>
|
||||
{/if}
|
||||
<a
|
||||
href="/status"
|
||||
class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import LoginInfoPanel from './LoginInfoPanel.svelte';
|
||||
import LoginPanel from './LoginPanel.svelte';
|
||||
import type { StatusResponse } from '../../types';
|
||||
import type { PublicStatusResponse } from '../../types';
|
||||
|
||||
const {
|
||||
isOAuthCallback,
|
||||
@@ -19,7 +19,7 @@
|
||||
authBusy: boolean;
|
||||
authError: string;
|
||||
authMessage: string;
|
||||
statusData: StatusResponse | null;
|
||||
statusData: PublicStatusResponse | null;
|
||||
statusLoading: boolean;
|
||||
statusError: string;
|
||||
onlogin: (username: string, password: string) => void;
|
||||
|
||||
@@ -1,11 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { StoredFile } from '../../types';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextAreaCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { StoredFile } from "../../types";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
|
||||
let files = $state<StoredFile[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
type FileForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
media_type: string;
|
||||
kind: string;
|
||||
project_id?: string;
|
||||
thought_id?: string;
|
||||
encoding?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
const FILE_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedFile = $state<StoredFile | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('update');
|
||||
let formValues = $state<FileForm>({ name: '', media_type: '', kind: 'file', encoding: 'base64', content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const filesOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/stored_files'
|
||||
}));
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 280 },
|
||||
{ id: 'media_type', title: 'Type', dataKey: 'media_type', width: 220 },
|
||||
{ id: 'kind', title: 'Kind', dataKey: 'kind', width: 120 },
|
||||
{ id: 'size_bytes', title: 'Size', dataKey: 'size_bytes', width: 120, format: 'number' },
|
||||
{ id: 'thought_id', title: 'Thought', dataKey: 'thought_id', width: 220 },
|
||||
{ id: 'project_id', title: 'Project', dataKey: 'project_id', width: 220 },
|
||||
{ id: 'created_at', title: 'Uploaded', dataKey: 'created_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
const filesDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'stored_files',
|
||||
uniqueID: FILE_PRIMARY_KEY,
|
||||
hotfields: [FILE_PRIMARY_KEY, 'guid', 'project_id', 'thought_id'],
|
||||
columns: ['id', 'guid', 'name', 'media_type', 'kind', 'size_bytes', 'created_at', 'updated_at', 'project_id', 'thought_id'],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
columns: string[];
|
||||
};
|
||||
|
||||
function normalizeFile(rowData: Record<string, unknown>): StoredFile {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
media_type: String(rowData.media_type ?? ''),
|
||||
kind: String(rowData.kind ?? ''),
|
||||
size_bytes: Number(rowData.size_bytes ?? 0),
|
||||
thought_id: typeof rowData.thought_id === 'string' ? rowData.thought_id : undefined,
|
||||
project_id: typeof rowData.project_id === 'string' ? rowData.project_id : undefined,
|
||||
sha256: String(rowData.sha256 ?? ''),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileRecordForFormer(data: Record<string, unknown>): FileForm {
|
||||
return {
|
||||
id: typeof data.id === 'string' || typeof data.id === 'number' ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
media_type: typeof data.media_type === 'string' ? data.media_type : '',
|
||||
kind: typeof data.kind === 'string' ? data.kind : 'file',
|
||||
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
|
||||
thought_id: typeof data.thought_id === 'string' && data.thought_id ? data.thought_id : undefined,
|
||||
encoding: typeof data.encoding === 'string' ? data.encoding : 'base64',
|
||||
content: typeof data.content === 'string' ? data.content : ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFileFromRow(rowData: Record<string, unknown>): Promise<FileForm> {
|
||||
const id = String(rowData[FILE_PRIMARY_KEY] ?? '');
|
||||
const data = await filesOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
|
||||
return normalizeFileRecordForFormer(data);
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedFile = rowData ? normalizeFile(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (!contextRow) return;
|
||||
formValues = normalizeFileRecordForFormer(contextRow);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeFileForm(data: FileForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
media_type: data.media_type.trim(),
|
||||
kind: data.kind.trim(),
|
||||
project_id: data.project_id?.trim() || undefined,
|
||||
thought_id: data.thought_id?.trim() || undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function handleFileSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow) {
|
||||
selectedFile = normalizeFile(contextRow);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -13,80 +164,145 @@
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
files = await api.files.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load files';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Files</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{files.length} file{files.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} file{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if files.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No files stored.</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Kind</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Size</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Uploaded</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each files as f}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="max-w-xs truncate px-4 py-3 font-medium text-white">{f.name}</td>
|
||||
<td class="px-4 py-3 text-slate-400 text-xs">{f.media_type}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">{f.kind || '—'}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{formatBytes(f.size_bytes)}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-400">{formatDate(f.created_at)}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a
|
||||
href={`/files/${f.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-cyan-400 hover:text-cyan-300"
|
||||
>↓</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="FilesGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={filesDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'media_type', 'kind', 'project_id', 'thought_id']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">File Inspector</h3>
|
||||
{#if !selectedFile}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a file row to inspect metadata.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedFile.name}</p>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p><strong class="text-slate-100">Type:</strong> {selectedFile.media_type}</p>
|
||||
<p><strong class="text-slate-100">Kind:</strong> {selectedFile.kind || '—'}</p>
|
||||
<p><strong class="text-slate-100">Size:</strong> {formatBytes(selectedFile.size_bytes)}</p>
|
||||
<p><strong class="text-slate-100">Thought:</strong> {selectedFile.thought_id || '—'}</p>
|
||||
<p><strong class="text-slate-100">Project:</strong> {selectedFile.project_id || '—'}</p>
|
||||
<p><strong class="text-slate-100">Uploaded:</strong> {formatDate(selectedFile.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedFile.updated_at)}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href={`/files/${selectedFile.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
>Download</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="FilesFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'update' ? 'Edit File' : 'Delete File'}
|
||||
uniqueKeyField={FILE_PRIMARY_KEY}
|
||||
onAPICall={filesOnAPICall}
|
||||
afterGet={async (data) => normalizeFileRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeFileForm}
|
||||
afterSave={handleFileSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Media Type"
|
||||
name="media_type"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.media_type ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, media_type: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Kind"
|
||||
name="kind"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.kind ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, kind: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Project ID"
|
||||
name="project_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.project_id ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Thought ID"
|
||||
name="thought_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.thought_id ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, thought_id: v || undefined })}
|
||||
/>
|
||||
{#if state.request !== 'delete'}
|
||||
<TextAreaCtrl
|
||||
label="Content"
|
||||
name="content"
|
||||
rows={4}
|
||||
disabled={true}
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,103 +1,407 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { AgentGuardrail } from '../../types';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { AgentGuardrail } from "../../types";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
const severityColour: Record<string, string> = {
|
||||
low: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
|
||||
medium: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
|
||||
high: 'border-orange-400/20 bg-orange-400/10 text-orange-200',
|
||||
critical: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
|
||||
type GuardrailForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
severity: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
let guardrails = $state<AgentGuardrail[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state<string | null>(null);
|
||||
const GUARDRAIL_PRIMARY_KEY = 'id';
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
guardrails = await api.guardrails.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load guardrails';
|
||||
} finally {
|
||||
loading = false;
|
||||
const severityClasses: Record<string, string> = {
|
||||
low: 'bg-emerald-400/10 text-emerald-200',
|
||||
medium: 'bg-amber-400/10 text-amber-200',
|
||||
high: 'bg-orange-400/10 text-orange-200',
|
||||
critical: 'bg-rose-400/10 text-rose-200'
|
||||
};
|
||||
|
||||
let selectedGuardrail = $state<AgentGuardrail | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<GuardrailForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
severity: 'medium',
|
||||
tags: ''
|
||||
});
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const guardrailOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_guardrails'
|
||||
}));
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 320 },
|
||||
{ id: 'severity', title: 'Severity', dataKey: 'severity', width: 120 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
const guardrailsDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_guardrails',
|
||||
uniqueID: GUARDRAIL_PRIMARY_KEY,
|
||||
hotfields: [GUARDRAIL_PRIMARY_KEY],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete guardrail "${name}"?`)) return;
|
||||
busy = id;
|
||||
try {
|
||||
await api.guardrails.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
function normalizeGuardrail(rowData: Record<string, unknown>): AgentGuardrail {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
content: String(rowData.content ?? ''),
|
||||
severity: (typeof rowData.severity === 'string' ? rowData.severity : 'medium') as AgentGuardrail['severity'],
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function toGuardrailForm(guardrail: AgentGuardrail): GuardrailForm {
|
||||
return {
|
||||
id: guardrail.id,
|
||||
name: guardrail.name,
|
||||
description: guardrail.description,
|
||||
content: guardrail.content,
|
||||
severity: guardrail.severity,
|
||||
tags: guardrail.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGuardrailRecordForFormer(data: Record<string, unknown>): GuardrailForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
severity: typeof data.severity === 'string' ? data.severity : 'medium',
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGuardrailFromRow(rowData: Record<string, unknown>): Promise<AgentGuardrail> {
|
||||
const id = String(rowData[GUARDRAIL_PRIMARY_KEY] ?? '');
|
||||
return await guardrailOnAPICall('read', 'update', undefined, id) as AgentGuardrail;
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedGuardrail = rowData ? normalizeGuardrail(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const guardrail = normalizeGuardrail(contextRow);
|
||||
selectedGuardrail = guardrail;
|
||||
editorValues = { id: guardrail.id, content: guardrail.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const guardrail = normalizeGuardrail(contextRow);
|
||||
formValues = toGuardrailForm(guardrail);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeGuardrailForm(data: GuardrailForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
content: data.content,
|
||||
severity: data.severity,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGuardrailSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
selectedGuardrail = await loadGuardrailFromRow(contextRow);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleGuardrailEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
selectedGuardrail = await guardrailOnAPICall('read', 'update', undefined, editorValues.id) as AgentGuardrail;
|
||||
editorValues = { id: selectedGuardrail.id, content: selectedGuardrail.content };
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Guardrails</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{guardrails.length} guardrail{guardrails.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} guardrail{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '', content: '', severity: 'medium', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New Guardrail</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="GuardrailsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={guardrailsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'content', 'severity', 'tags']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if guardrails.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No guardrails registered.</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each guardrails as g}
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-semibold text-white">{g.name}</p>
|
||||
<span class={`rounded-full border px-2 py-0.5 text-xs font-medium ${severityColour[g.severity] ?? severityColour.medium}`}>
|
||||
{g.severity}
|
||||
</span>
|
||||
</div>
|
||||
{#if g.description}
|
||||
<p class="mt-1 text-sm text-slate-400">{g.description}</p>
|
||||
{/if}
|
||||
{#if g.tags?.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each g.tags as tag}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white">Guardrail Inspector</h3>
|
||||
{#if selectedGuardrail}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const guardrail = selectedGuardrail;
|
||||
if (!guardrail) return;
|
||||
editorValues = { id: guardrail.id, content: guardrail.content };
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedGuardrail}
|
||||
<p class="mt-3 text-sm text-slate-500">Select a guardrail row to inspect its rule content.</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedGuardrail.name}</p>
|
||||
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${severityClasses[selectedGuardrail.severity] ?? severityClasses.medium}`}>
|
||||
{selectedGuardrail.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedGuardrail.description || '—'}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedGuardrail.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedGuardrail.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedGuardrail.content}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#if selectedGuardrail.tags.length}
|
||||
{#each selectedGuardrail.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||
onclick={() => remove(g.id, g.name)}
|
||||
disabled={busy === g.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{g.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="GuardrailsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Guardrail Content"
|
||||
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={guardrailOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleGuardrailEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="guardrail.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="GuardrailsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Guardrail' : formRequest === 'update' ? 'Edit Guardrail' : 'Delete Guardrail'}
|
||||
uniqueKeyField={GUARDRAIL_PRIMARY_KEY}
|
||||
onAPICall={guardrailOnAPICall}
|
||||
afterGet={async (data) => normalizeGuardrailRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeGuardrailForm}
|
||||
afterSave={handleGuardrailSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="guardrail.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Severity"
|
||||
name="severity"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.severity ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, severity: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,18 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
SwitchCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { Learning } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type LearningForm = {
|
||||
id?: string;
|
||||
summary: string;
|
||||
details: string;
|
||||
category: string;
|
||||
area: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
confidence: string;
|
||||
action_required: boolean;
|
||||
source_type?: string;
|
||||
source_ref?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const LEARNING_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedLearning = $state<Learning | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<LearningForm>({ summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; details: string }>({ details: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const learningOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/learnings'
|
||||
}));
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function toLearningForm(learning: Learning): LearningForm {
|
||||
return {
|
||||
id: learning.id,
|
||||
summary: learning.summary,
|
||||
details: learning.details,
|
||||
category: learning.category,
|
||||
area: learning.area,
|
||||
status: learning.status,
|
||||
priority: learning.priority,
|
||||
confidence: learning.confidence,
|
||||
action_required: learning.action_required,
|
||||
source_type: learning.source_type,
|
||||
source_ref: learning.source_ref,
|
||||
tags: learning.tags
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLearningRecordForFormer(data: Record<string, unknown>): LearningForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
summary: typeof data.summary === 'string' ? data.summary : '',
|
||||
details: typeof data.details === 'string' ? data.details : '',
|
||||
category: typeof data.category === 'string' ? data.category : '',
|
||||
area: typeof data.area === 'string' ? data.area : '',
|
||||
status: typeof data.status === 'string' ? data.status : 'active',
|
||||
priority: typeof data.priority === 'string' ? data.priority : 'medium',
|
||||
confidence: typeof data.confidence === 'string' ? data.confidence : 'medium',
|
||||
action_required: Boolean(data.action_required),
|
||||
source_type: typeof data.source_type === 'string' && data.source_type ? data.source_type : undefined,
|
||||
source_ref: typeof data.source_ref === 'string' && data.source_ref ? data.source_ref : undefined,
|
||||
tags: typeof data.tags === 'string' ? data.tags : undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLearningFromRow(rowData: Record<string, unknown>): Promise<Learning> {
|
||||
const id = String(rowData[LEARNING_PRIMARY_KEY] ?? '');
|
||||
return await learningOnAPICall('read', 'update', undefined, id) as Learning;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const learning = normalizeLearning(contextRow);
|
||||
selectedLearning = learning;
|
||||
editorValues = { id: learning.id, details: learning.details };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const learning = normalizeLearning(contextRow);
|
||||
formValues = toLearningForm(learning);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizeLearningForm(data: LearningForm): Record<string, unknown> {
|
||||
return {
|
||||
summary: data.summary.trim(),
|
||||
details: data.details,
|
||||
category: data.category.trim(),
|
||||
area: data.area.trim(),
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
confidence: data.confidence,
|
||||
action_required: data.action_required,
|
||||
source_type: data.source_type?.trim() || undefined,
|
||||
source_ref: data.source_ref?.trim() || undefined,
|
||||
tags: data.tags?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLearningSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
selectedLearning = await loadLearningFromRow(contextRow);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handleLearningEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
selectedLearning = await learningOnAPICall('read', 'update', undefined, editorValues.id) as Learning;
|
||||
editorValues = { id: selectedLearning.id, details: selectedLearning.details };
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
const learningsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "learnings",
|
||||
uniqueID: "id",
|
||||
uniqueID: LEARNING_PRIMARY_KEY,
|
||||
hotfields: [LEARNING_PRIMARY_KEY],
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -20,6 +165,7 @@
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
@@ -90,6 +236,12 @@
|
||||
selectedLearning = normalizeLearning(rowData);
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
@@ -120,29 +272,53 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { summary: '', details: '', category: '', area: '', status: 'active', priority: 'medium', confidence: 'medium', action_required: false }; formRequest = 'insert'; formOpened = true; }}>New Learning</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={learningsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["summary", "details", "category", "area", "status"]}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="LearningsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={learningsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["summary", "details", "category", "area", "status"]}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Learning Inspector</h3>
|
||||
{#if selectedLearning}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const learning = selectedLearning;
|
||||
if (!learning) return;
|
||||
editorValues = { id: learning.id, details: learning.details };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedLearning}
|
||||
@@ -217,3 +393,127 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="LearningsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Learning Details"
|
||||
uniqueKeyField={LEARNING_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={learningOnAPICall}
|
||||
beforeSave={(data) => ({ details: data.details })}
|
||||
afterSave={handleLearningEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="learning.md"
|
||||
value={state.values?.details ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, details: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="LearningsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Learning' : formRequest === 'update' ? 'Edit Learning' : 'Delete Learning'}
|
||||
uniqueKeyField={LEARNING_PRIMARY_KEY}
|
||||
onAPICall={learningOnAPICall}
|
||||
afterGet={async (data) => normalizeLearningRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeLearningForm}
|
||||
afterSave={handleLearningSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Summary"
|
||||
name="summary"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.summary ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, summary: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="learning.md"
|
||||
value={state.values?.details ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, details: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Category"
|
||||
name="category"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.category ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, category: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Area"
|
||||
name="area"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.area ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, area: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Status"
|
||||
name="status"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.status ?? 'active'}
|
||||
options={['active', 'completed', 'pending', 'archived']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, status: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Priority"
|
||||
name="priority"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.priority ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Confidence"
|
||||
name="confidence"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.confidence ?? 'medium'}
|
||||
options={['low', 'medium', 'high']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, confidence: v })}
|
||||
/>
|
||||
<SwitchCtrl
|
||||
label="Action Required"
|
||||
name="action_required"
|
||||
disabled={state.request === 'delete'}
|
||||
checked={state.values?.action_required ?? false}
|
||||
onchange={(v) => state.setState('values', { ...state.values, action_required: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Source Type"
|
||||
name="source_type"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.source_type ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, source_type: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Source Ref"
|
||||
name="source_ref"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.source_ref ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, source_ref: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
456
ui/src/components/plans/PlansPage.svelte
Normal file
456
ui/src/components/plans/PlansPage.svelte
Normal file
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { Plan } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type PlanForm = {
|
||||
id?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
owner?: string;
|
||||
due_date?: string;
|
||||
tags?: string;
|
||||
};
|
||||
|
||||
const PLAN_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedPlan = $state<Plan | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<PlanForm>({ title: '', description: '', status: 'draft', priority: 'medium' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const planOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/plans'
|
||||
}));
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function loadPlanFromRow(rowData: Record<string, unknown>): Promise<Plan> {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(rowData[PLAN_PRIMARY_KEY] ?? '')) as Record<string, unknown>;
|
||||
return normalizePlan(data);
|
||||
}
|
||||
|
||||
function toPlanForm(plan: Plan): PlanForm {
|
||||
return {
|
||||
id: plan.id,
|
||||
title: plan.title,
|
||||
description: plan.description,
|
||||
status: plan.status,
|
||||
priority: plan.priority,
|
||||
owner: plan.owner,
|
||||
due_date: plan.due_date ? String(plan.due_date).slice(0, 10) : undefined,
|
||||
tags: plan.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlanRecordForFormer(data: Record<string, unknown>): PlanForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
title: typeof data.title === 'string' ? data.title : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
status: typeof data.status === 'string' ? data.status : 'draft',
|
||||
priority: typeof data.priority === 'string' ? data.priority : 'medium',
|
||||
owner: typeof data.owner === 'string' && data.owner ? data.owner : undefined,
|
||||
due_date: typeof data.due_date === 'string' && data.due_date ? data.due_date.slice(0, 10) : undefined,
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { title: '', description: '', status: 'draft', priority: 'medium' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const plan = normalizePlan(contextRow);
|
||||
selectedPlan = plan;
|
||||
editorValues = { id: plan.id, description: plan.description };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const plan = normalizePlan(contextRow);
|
||||
formValues = toPlanForm(plan);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizePlanForm(data: PlanForm): Record<string, unknown> {
|
||||
return {
|
||||
title: data.title.trim(),
|
||||
description: data.description,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
owner: data.owner?.trim() || undefined,
|
||||
due_date: data.due_date || undefined,
|
||||
tags: data.tags?.split(',').map((t) => t.trim()).filter(Boolean) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePlanSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(contextRow.id)) as Record<string, unknown>;
|
||||
selectedPlan = normalizePlan(data);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handlePlanEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const data = await planOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
|
||||
selectedPlan = normalizePlan(data);
|
||||
editorValues = { id: selectedPlan.id, description: selectedPlan.description };
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
const plansDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "plans",
|
||||
uniqueID: PLAN_PRIMARY_KEY,
|
||||
hotfields: [PLAN_PRIMARY_KEY],
|
||||
sort: [{ column: "updated_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: "title", title: "Title", dataKey: "title", width: 340 },
|
||||
{ id: "status", title: "Status", dataKey: "status", width: 120 },
|
||||
{ id: "priority", title: "Priority", dataKey: "priority", width: 110 },
|
||||
{ id: "owner", title: "Owner", dataKey: "owner", width: 160 },
|
||||
{ id: "due_date", title: "Due", dataKey: "due_date", width: 180, format: "datetime" },
|
||||
{ id: "last_reviewed_at", title: "Reviewed", dataKey: "last_reviewed_at", width: 180, format: "datetime" },
|
||||
{ id: "updated_at", title: "Updated", dataKey: "updated_at", width: 180, format: "datetime" },
|
||||
];
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
|
||||
if (typeof value !== "string" || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
return trimmed.slice(1, -1).split(",").map((t) => t.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
|
||||
}
|
||||
return trimmed.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePlan(rowData: Record<string, unknown>): Plan {
|
||||
return {
|
||||
id: String(rowData.id ?? ""),
|
||||
title: typeof rowData.title === "string" ? rowData.title : "",
|
||||
description: typeof rowData.description === "string" ? rowData.description : "",
|
||||
status: (typeof rowData.status === "string" ? rowData.status : "draft") as Plan["status"],
|
||||
priority: (typeof rowData.priority === "string" ? rowData.priority : "medium") as Plan["priority"],
|
||||
project_id: typeof rowData.project_id === "string" ? rowData.project_id : undefined,
|
||||
owner: typeof rowData.owner === "string" && rowData.owner ? rowData.owner : undefined,
|
||||
due_date: typeof rowData.due_date === "string" ? rowData.due_date : undefined,
|
||||
completed_at: typeof rowData.completed_at === "string" ? rowData.completed_at : undefined,
|
||||
reviewed_by: typeof rowData.reviewed_by === "string" ? rowData.reviewed_by : undefined,
|
||||
last_reviewed_at: typeof rowData.last_reviewed_at === "string" ? rowData.last_reviewed_at : undefined,
|
||||
supersedes_plan_id: typeof rowData.supersedes_plan_id === "string" ? rowData.supersedes_plan_id : undefined,
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ""),
|
||||
updated_at: String(rowData.updated_at ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedPlan = rowData ? normalizePlan(rowData) : null;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>,
|
||||
) {
|
||||
if (type !== "page_loaded" && type !== "load") return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === "number") gridTotal = total;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: "bg-slate-700/60 text-slate-300",
|
||||
active: "bg-cyan-900/60 text-cyan-200",
|
||||
blocked: "bg-amber-900/60 text-amber-200",
|
||||
completed: "bg-emerald-900/60 text-emerald-200",
|
||||
cancelled: "bg-slate-800/60 text-slate-500",
|
||||
superseded: "bg-purple-900/60 text-purple-300",
|
||||
};
|
||||
|
||||
const priorityClasses: Record<string, string> = {
|
||||
low: "bg-slate-700/60 text-slate-400",
|
||||
medium: "bg-cyan-900/60 text-cyan-300",
|
||||
high: "bg-amber-900/60 text-amber-300",
|
||||
critical: "bg-rose-900/60 text-rose-300",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Plans</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} plan{gridTotal !== 1 ? "s" : ""}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { title: '', description: '', status: 'draft', priority: 'medium' }; formRequest = 'insert'; formOpened = true; }}>New Plan</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="PlansGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={plansDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["title", "description", "status", "priority", "owner"]}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-white">Plan Inspector</h3>
|
||||
{#if selectedPlan}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const plan = selectedPlan;
|
||||
if (!plan) return;
|
||||
editorValues = { id: plan.id, description: plan.description };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedPlan}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a plan row to inspect details and relationships.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedPlan.title}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${statusClasses[selectedPlan.status] ?? "bg-slate-700/60 text-slate-300"}`}>
|
||||
{selectedPlan.status}
|
||||
</span>
|
||||
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${priorityClasses[selectedPlan.priority] ?? "bg-slate-700/60 text-slate-300"}`}>
|
||||
{selectedPlan.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
<p><strong class="text-slate-100">Owner:</strong> {selectedPlan.owner || "—"}</p>
|
||||
<p><strong class="text-slate-100">Due:</strong> {formatDate(selectedPlan.due_date)}</p>
|
||||
<p><strong class="text-slate-100">Completed:</strong> {formatDate(selectedPlan.completed_at)}</p>
|
||||
<p><strong class="text-slate-100">Last reviewed:</strong> {formatDate(selectedPlan.last_reviewed_at)}</p>
|
||||
<p><strong class="text-slate-100">Reviewed by:</strong> {selectedPlan.reviewed_by || "—"}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedPlan.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedPlan.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
{#if selectedPlan.description}
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Description</p>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedPlan.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedPlan.tags.length > 0}
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each selectedPlan.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedPlan.project_id || selectedPlan.supersedes_plan_id}
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
|
||||
{#if selectedPlan.project_id}
|
||||
<p><strong class="text-slate-100">Project:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.project_id}</span></p>
|
||||
{/if}
|
||||
{#if selectedPlan.supersedes_plan_id}
|
||||
<p><strong class="text-slate-100">Supersedes:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.supersedes_plan_id}</span></p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="PlansEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Plan Description"
|
||||
uniqueKeyField={PLAN_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={planOnAPICall}
|
||||
beforeSave={(data) => ({ description: data.description })}
|
||||
afterSave={handlePlanEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="plan.md"
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="PlansFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Plan' : formRequest === 'update' ? 'Edit Plan' : 'Delete Plan'}
|
||||
uniqueKeyField={PLAN_PRIMARY_KEY}
|
||||
onAPICall={planOnAPICall}
|
||||
afterGet={async (data) => normalizePlanRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizePlanForm}
|
||||
afterSave={handlePlanSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<TextInputCtrl
|
||||
label="Title"
|
||||
name="title"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.title ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, title: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="plan.md"
|
||||
value={state.values?.description ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Status"
|
||||
name="status"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.status ?? 'draft'}
|
||||
options={['draft', 'active', 'blocked', 'completed', 'cancelled', 'superseded']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, status: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Priority"
|
||||
name="priority"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.priority ?? 'medium'}
|
||||
options={['low', 'medium', 'high', 'critical']}
|
||||
onchange={(v) => state.setState('values', { ...state.values, priority: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Owner"
|
||||
name="owner"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.owner ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, owner: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Due Date"
|
||||
name="due_date"
|
||||
type="date"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.due_date ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, due_date: v || undefined })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
@@ -1,27 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { GridlerFull, TextInputCtrl, type GridlerColumn } from '@warkypublic/svelix';
|
||||
import { api } from '../../api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import type { ProjectSummary } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
type ProjectForm = {
|
||||
id?: string;
|
||||
guid?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PROJECT_PRIMARY_KEY = 'id';
|
||||
|
||||
let projects = $state<ProjectSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let creating = $state(false);
|
||||
let showCreate = $state(false);
|
||||
let newName = $state('');
|
||||
let newDesc = $state('');
|
||||
let createError = $state('');
|
||||
let selectedProject = $state<ProjectSummary | null>(null);
|
||||
let selectedProjectRecordID = $state<string | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<ProjectForm>({ name: '', description: '' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; description: string }>({ description: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const projectOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/projects'
|
||||
}));
|
||||
|
||||
const projectDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'projects',
|
||||
uniqueID: 'id',
|
||||
hotfields: ['id', 'guid'],
|
||||
uniqueID: PROJECT_PRIMARY_KEY,
|
||||
hotfields: [PROJECT_PRIMARY_KEY, 'guid'],
|
||||
computedColumns: [
|
||||
{
|
||||
name: 'thought_count',
|
||||
expression: 'COALESCE((SELECT COUNT(*) FROM public.thoughts t WHERE t.project_id = projects.guid), 0)'
|
||||
}
|
||||
],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -30,6 +59,7 @@
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
computedColumns: { name: string; expression: string }[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
@@ -39,44 +69,30 @@
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 200, format: 'datetime' }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.projects.list();
|
||||
if (selectedProject) {
|
||||
selectedProject = projects.find((project) => project.id === selectedProject?.id) ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load projects';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
createError = '';
|
||||
try {
|
||||
await api.projects.create(newName.trim(), newDesc.trim());
|
||||
newName = '';
|
||||
newDesc = '';
|
||||
showCreate = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError = e instanceof Error ? e.message : 'Failed to create project';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
function toProjectForm(rowData: Record<string, unknown>): ProjectForm {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
guid: String(rowData.guid ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function onProjectRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) {
|
||||
selectedProject = null;
|
||||
selectedProjectRecordID = null;
|
||||
return;
|
||||
}
|
||||
const id = String(rowData.guid ?? rowData.id ?? '');
|
||||
selectedProjectRecordID = String(rowData[PROJECT_PRIMARY_KEY] ?? '');
|
||||
const id = String(rowData.guid ?? rowData[PROJECT_PRIMARY_KEY] ?? '');
|
||||
selectedProject = {
|
||||
id,
|
||||
name: String(rowData.name ?? ''),
|
||||
@@ -87,88 +103,186 @@
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
onProjectRowClick(0, contextRow);
|
||||
editorValues = {
|
||||
id: String(contextRow[PROJECT_PRIMARY_KEY] ?? ''),
|
||||
description: String(contextRow.description ?? '')
|
||||
};
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
formValues = toProjectForm(contextRow);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function normalizeProjectForm(data: ProjectForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim()
|
||||
};
|
||||
}
|
||||
|
||||
async function handleProjectSaved() {
|
||||
formOpened = false;
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (selectedProjectRecordID && editorValues.id === selectedProjectRecordID && selectedProject) {
|
||||
selectedProject.description = editorValues.description;
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Projects</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} project{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="inline-flex items-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
<button
|
||||
class="inline-flex items-center rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => { showCreate = !showCreate; }}
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
|
||||
<h3 class="text-sm font-semibold text-white">Create project</h3>
|
||||
<div class="mt-3 space-y-3">
|
||||
<TextInputCtrl
|
||||
label="Project name"
|
||||
placeholder="Name"
|
||||
required
|
||||
bind:value={newName}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="ProjectsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={projectDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description']}
|
||||
{menuItems}
|
||||
onRowClick={onProjectRowClick}
|
||||
onGridEvent={(type, _item, _column, _coords, detail) => {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
placeholder="Description (optional)"
|
||||
bind:value={newDesc}
|
||||
/>
|
||||
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
|
||||
onclick={create}
|
||||
disabled={creating || !newName.trim()}
|
||||
>{creating ? 'Creating…' : 'Create'}</button>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
|
||||
onclick={() => { showCreate = false; createError = ''; }}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">
|
||||
No projects yet.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={projectDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description']}
|
||||
onRowClick={onProjectRowClick}
|
||||
/>
|
||||
</div>
|
||||
{#if selectedProject}
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{#if selectedProject}
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="text-sm font-semibold text-white">Selected project</h3>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Selected project</h3>
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const project = selectedProject;
|
||||
if (!project) return;
|
||||
editorValues = {
|
||||
id: selectedProjectRecordID ?? undefined,
|
||||
description: project.description
|
||||
};
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-300"><strong class="text-slate-100">{selectedProject.name}</strong> · {selectedProject.thought_count} thoughts</p>
|
||||
<p class="mt-1 text-sm text-slate-400">{selectedProject.description || 'No description.'}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="ProjectsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Project Description"
|
||||
uniqueKeyField={PROJECT_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={projectOnAPICall}
|
||||
beforeSave={(data) => ({ description: data.description.trim() })}
|
||||
afterSave={handleEditorSaved}
|
||||
onClose={() => {
|
||||
editorOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="project.md"
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="ProjectsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Project' : formRequest === 'update' ? 'Edit Project' : 'Delete Project'}
|
||||
uniqueKeyField={PROJECT_PRIMARY_KEY}
|
||||
onAPICall={projectOnAPICall}
|
||||
beforeSave={normalizeProjectForm}
|
||||
afterSave={handleProjectSaved}
|
||||
onClose={() => {
|
||||
formOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Project name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="project.md"
|
||||
value={state.values?.description ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
44
ui/src/components/shared/ContentEditorField.svelte
Normal file
44
ui/src/components/shared/ContentEditorField.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { ContentEditor } from '@warkypublic/svelix';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
filename: string;
|
||||
disabled?: boolean;
|
||||
onchange: (text: string) => void;
|
||||
}
|
||||
|
||||
let { value, filename, disabled = false, onchange }: Props = $props();
|
||||
|
||||
let currentBlob = $state<Blob | undefined>(undefined);
|
||||
|
||||
const initialBlob = $derived(new Blob([value ?? ''], { type: 'text/plain' }));
|
||||
|
||||
$effect(() => {
|
||||
currentBlob = undefined;
|
||||
});
|
||||
|
||||
async function handleBlobChange(blob?: Blob) {
|
||||
currentBlob = blob;
|
||||
if (!blob) {
|
||||
onchange('');
|
||||
return;
|
||||
}
|
||||
onchange(await blob.text());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-white/10 bg-slate-950/60 overflow-hidden">
|
||||
<div class="border-b border-white/10 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||
{filename}
|
||||
</div>
|
||||
<div class:opacity-60={disabled} class:pointer-events-none={disabled}>
|
||||
<ContentEditor
|
||||
value={currentBlob ?? initialBlob}
|
||||
{filename}
|
||||
colorScheme="dark"
|
||||
hideHeader={true}
|
||||
onChange={(blob) => { void handleBlobChange(blob); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
94
ui/src/components/shared/ContentEditorModal.svelte
Normal file
94
ui/src/components/shared/ContentEditorModal.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { ContentEditor } from '@warkypublic/svelix';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
filename: string;
|
||||
onSave: (text: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open, title, content, filename, onSave, onClose }: Props = $props();
|
||||
|
||||
let saveError = $state('');
|
||||
let saving = $state(false);
|
||||
let fullscreen = $state(false);
|
||||
let currentBlob = $state<Blob | undefined>(undefined);
|
||||
|
||||
const initialBlob = $derived(open ? new Blob([content], { type: 'text/plain' }) : undefined);
|
||||
|
||||
$effect(() => {
|
||||
if (open) currentBlob = undefined;
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const blob = currentBlob ?? initialBlob;
|
||||
if (!blob) return;
|
||||
saveError = '';
|
||||
saving = true;
|
||||
try {
|
||||
const text = await blob.text();
|
||||
await onSave(text);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
saveError = e instanceof Error ? e.message : 'Save failed';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { if (fullscreen) { fullscreen = false; } else { onClose(); } }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-8" class:p-0={fullscreen}>
|
||||
<div
|
||||
class="flex flex-col w-full max-w-5xl bg-slate-900 transition-all"
|
||||
class:max-w-5xl={!fullscreen}
|
||||
style={fullscreen ? 'height: 100vh; border-radius: 0; border: none;' : 'height: 85vh; border-radius: 1rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1);'}
|
||||
>
|
||||
<div class="flex-none flex items-center justify-between px-5 py-3 border-b border-white/10 bg-slate-800">
|
||||
<h2 class="text-sm font-semibold text-white">{title}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if saveError}
|
||||
<p class="text-xs text-rose-300">{saveError}</p>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => { fullscreen = !fullscreen; }}
|
||||
class="text-xs text-slate-400 hover:text-white"
|
||||
title={fullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>{fullscreen ? '⊡' : '⛶'}</button>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-xs text-slate-400 hover:text-white"
|
||||
>Cancel</button>
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
class="rounded-lg bg-cyan-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
|
||||
>{saving ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden bg-slate-900">
|
||||
<ContentEditor
|
||||
value={initialBlob}
|
||||
{filename}
|
||||
colorScheme="dark"
|
||||
hideHeader={true}
|
||||
onChange={(v) => { currentBlob = v; }}
|
||||
onSave={save}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
38
ui/src/components/shared/FormerShell.svelte
Normal file
38
ui/src/components/shared/FormerShell.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { FormerDrawer } from '@warkypublic/svelix';
|
||||
import type { FormerProps, FormRequestType } from '@warkypublic/svelix';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props extends FormerProps<any> {
|
||||
title?: string;
|
||||
width?: string;
|
||||
children?: Snippet<[any]>;
|
||||
}
|
||||
|
||||
let {
|
||||
title = 'Form',
|
||||
opened = $bindable(false),
|
||||
values = $bindable<any>(undefined),
|
||||
request = $bindable<FormRequestType>('insert'),
|
||||
layout = { buttonArea: 'bottom' },
|
||||
width = '36rem',
|
||||
children: formContent,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<FormerDrawer
|
||||
bind:opened
|
||||
bind:values
|
||||
bind:request
|
||||
{title}
|
||||
{layout}
|
||||
{width}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-6">
|
||||
{@render formContent?.(state)}
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerDrawer>
|
||||
@@ -3,6 +3,7 @@
|
||||
import FilesPage from '../files/FilesPage.svelte';
|
||||
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
|
||||
import LearningsPage from '../learnings/LearningsPage.svelte';
|
||||
import PlansPage from '../plans/PlansPage.svelte';
|
||||
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
|
||||
import DashboardPage from '../dashboard/DashboardPage.svelte';
|
||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||
@@ -41,6 +42,8 @@
|
||||
<ThoughtsPage />
|
||||
{:else if currentPage === 'learnings'}
|
||||
<LearningsPage />
|
||||
{:else if currentPage === 'plans'}
|
||||
<PlansPage />
|
||||
{:else if currentPage === 'skills'}
|
||||
<SkillsPage />
|
||||
{:else if currentPage === 'guardrails'}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
||||
{ id: 'plans', label: 'Plans', description: 'Structured plans and workstreams.' },
|
||||
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
|
||||
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||
|
||||
@@ -1,91 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
TextInputCtrl,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem
|
||||
} from '@warkypublic/svelix';
|
||||
import { adminGridTheme } from '../../gridTheme';
|
||||
import { GlobalStateStore } from '../../shellState';
|
||||
import type { AgentSkill } from '../../types';
|
||||
import FormerShell from '../shared/FormerShell.svelte';
|
||||
import ContentEditorField from '../shared/ContentEditorField.svelte';
|
||||
|
||||
let skills = $state<AgentSkill[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state<string | null>(null);
|
||||
type SkillForm = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
skills = await api.skills.list();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load skills';
|
||||
} finally {
|
||||
loading = false;
|
||||
const SKILL_PRIMARY_KEY = 'id';
|
||||
|
||||
let selectedSkill = $state<AgentSkill | null>(null);
|
||||
let gridTotal = $state<number | null>(null);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<SkillForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
content: '',
|
||||
tags: ''
|
||||
});
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const skillOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/agent_skills'
|
||||
}));
|
||||
|
||||
const skillsDataSourceOptions = {
|
||||
url: '/api/rs',
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: 'public',
|
||||
entity: 'agent_skills',
|
||||
uniqueID: SKILL_PRIMARY_KEY,
|
||||
hotfields: [SKILL_PRIMARY_KEY],
|
||||
sort: [{ column: 'created_at', direction: 'desc' }]
|
||||
} as unknown as {
|
||||
url: string;
|
||||
authToken?: string;
|
||||
schema: string;
|
||||
entity: string;
|
||||
uniqueID: string;
|
||||
hotfields: string[];
|
||||
};
|
||||
|
||||
const columns: GridlerColumn[] = [
|
||||
{ id: 'name', title: 'Name', dataKey: 'name', width: 240 },
|
||||
{ id: 'description', title: 'Description', dataKey: 'description', width: 300 },
|
||||
{ id: 'tags', title: 'Tags', dataKey: 'tags', width: 220 },
|
||||
{ id: 'created_at', title: 'Created', dataKey: 'created_at', width: 180, format: 'datetime' },
|
||||
{ id: 'updated_at', title: 'Updated', dataKey: 'updated_at', width: 180, format: 'datetime' }
|
||||
];
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' }
|
||||
];
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
if (typeof value !== 'string' || !value.trim()) return [];
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim().replace(/^"(.*)"$/, '$1'))
|
||||
.filter(Boolean);
|
||||
}
|
||||
return trimmed.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function remove(id: string, name: string) {
|
||||
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||
busy = id;
|
||||
try {
|
||||
await api.skills.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete failed';
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
function normalizeSkill(rowData: Record<string, unknown>): AgentSkill {
|
||||
return {
|
||||
id: String(rowData.id ?? ''),
|
||||
name: String(rowData.name ?? ''),
|
||||
description: String(rowData.description ?? ''),
|
||||
content: String(rowData.content ?? ''),
|
||||
tags: normalizeTags(rowData.tags),
|
||||
created_at: String(rowData.created_at ?? ''),
|
||||
updated_at: String(rowData.updated_at ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
function toSkillForm(skill: AgentSkill): SkillForm {
|
||||
return {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: skill.content,
|
||||
tags: skill.tags.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillRecordForFormer(data: Record<string, unknown>): SkillForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
name: typeof data.name === 'string' ? data.name : '',
|
||||
description: typeof data.description === 'string' ? data.description : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
tags: normalizeTags(data.tags).join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
selectedSkill = rowData ? normalizeSkill(rowData) : null;
|
||||
}
|
||||
|
||||
async function loadSkillFromRow(rowData: Record<string, unknown>): Promise<AgentSkill> {
|
||||
const id = String(rowData[SKILL_PRIMARY_KEY] ?? '');
|
||||
const data = await skillOnAPICall('read', 'update', undefined, id) as Record<string, unknown>;
|
||||
return normalizeSkill(data);
|
||||
}
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { name: '', description: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const skill = normalizeSkill(contextRow);
|
||||
selectedSkill = skill;
|
||||
editorValues = { id: skill.id, content: skill.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const skill = normalizeSkill(contextRow);
|
||||
formValues = toSkillForm(skill);
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
_column?: unknown,
|
||||
_coords?: unknown,
|
||||
detail?: Record<string, unknown>
|
||||
) {
|
||||
if (type !== 'page_loaded' && type !== 'load') return;
|
||||
const total = detail?.total;
|
||||
if (typeof total === 'number') gridTotal = total;
|
||||
}
|
||||
|
||||
function normalizeSkillForm(data: SkillForm): Record<string, unknown> {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
description: data.description.trim(),
|
||||
content: data.content,
|
||||
tags: data.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSkillSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.[SKILL_PRIMARY_KEY]) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(contextRow[SKILL_PRIMARY_KEY])) as Record<string, unknown>;
|
||||
selectedSkill = normalizeSkill(data);
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
async function handleEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const data = await skillOnAPICall('read', 'update', undefined, String(editorValues.id)) as Record<string, unknown>;
|
||||
const refreshed = normalizeSkill(data);
|
||||
selectedSkill = refreshed;
|
||||
editorValues = { id: refreshed.id, content: refreshed.content };
|
||||
}
|
||||
refreshKey += 1;
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="space-y-4 w-full">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Skills</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{#if gridTotal === null}
|
||||
Server-backed grid
|
||||
{:else}
|
||||
{gridTotal} skill{gridTotal !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||
onclick={() => {
|
||||
formValues = { name: '', description: '', content: '', tags: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
}}
|
||||
>New Skill</button>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={load}
|
||||
>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||
{:else if skills.length === 0}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each skills as skill}
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-white">{skill.name}</p>
|
||||
{#if skill.description}
|
||||
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
|
||||
{/if}
|
||||
{#if skill.tags?.length}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each skill.tags as tag}
|
||||
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||
onclick={() => remove(skill.id, skill.name)}
|
||||
disabled={busy === skill.id}
|
||||
>Delete</button>
|
||||
</div>
|
||||
<details class="mt-3">
|
||||
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="SkillsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={420}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={skillsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={['name', 'description', 'content', 'tags']}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-white">Skill Inspector</h3>
|
||||
{#if selectedSkill}
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
if (!selectedSkill) return;
|
||||
editorValues = { id: selectedSkill.id, content: selectedSkill.content };
|
||||
editorOpened = true;
|
||||
}}
|
||||
>Edit Content</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedSkill}
|
||||
<p class="mt-3 text-sm text-slate-500">
|
||||
Select a skill row to inspect details.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3 text-sm text-slate-300">
|
||||
<p class="text-base font-semibold text-slate-100">{selectedSkill.name}</p>
|
||||
<p><strong class="text-slate-100">Description:</strong> {selectedSkill.description || '—'}</p>
|
||||
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedSkill.created_at)}</p>
|
||||
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedSkill.updated_at)}</p>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#if selectedSkill.tags.length}
|
||||
{#each selectedSkill.tags as tag}
|
||||
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-slate-500">No tags</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Content</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{selectedSkill.content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="SkillsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Skill Content"
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={skillOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleEditorSaved}
|
||||
onClose={() => {
|
||||
editorOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="SkillsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Skill' : formRequest === 'update' ? 'Edit Skill' : 'Delete Skill'}
|
||||
uniqueKeyField={SKILL_PRIMARY_KEY}
|
||||
onAPICall={skillOnAPICall}
|
||||
afterGet={async (data) => normalizeSkillRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeSkillForm}
|
||||
afterSave={handleSkillSaved}
|
||||
onClose={() => {
|
||||
formOpened = false;
|
||||
}}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<TextInputCtrl
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.name ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, name: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Description"
|
||||
name="description"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.description ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, description: v })}
|
||||
/>
|
||||
<TextInputCtrl
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="comma-separated"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.tags ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, tags: v })}
|
||||
/>
|
||||
<ContentEditorField
|
||||
filename="skill.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ErrorBoundary,
|
||||
FormerResolveSpecAPI,
|
||||
GridlerFull,
|
||||
NativeSelectCtrl,
|
||||
type GridColumnFilters,
|
||||
type GridlerColumn,
|
||||
type GridlerContextMenuItem,
|
||||
} from "@warkypublic/svelix";
|
||||
import FormerShell from "../shared/FormerShell.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "../../api";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import { adminGridTheme } from "../../gridTheme";
|
||||
import { GlobalStateStore } from "../../shellState";
|
||||
import type { StoredFile, Thought, ThoughtLink } from "../../types";
|
||||
import ContentEditorField from "../shared/ContentEditorField.svelte";
|
||||
|
||||
type ThoughtForm = { id?: string; content: string; project_id?: string };
|
||||
|
||||
const THOUGHT_PRIMARY_KEY = 'id';
|
||||
|
||||
let includeArchived = $state(false);
|
||||
let actionBusy = $state<string | null>(null);
|
||||
@@ -16,14 +27,107 @@
|
||||
let selectedThought = $state<Thought | null>(null);
|
||||
let relatedLinks = $state<ThoughtLink[]>([]);
|
||||
let relatedFiles = $state<StoredFile[]>([]);
|
||||
let formOpened = $state(false);
|
||||
let formRequest = $state<'insert' | 'update' | 'delete'>('insert');
|
||||
let formValues = $state<ThoughtForm>({ content: '' });
|
||||
let editorOpened = $state(false);
|
||||
let editorValues = $state<{ id?: string; content: string }>({ content: '' });
|
||||
let contextRow = $state<Record<string, unknown> | null>(null);
|
||||
let refreshKey = $state(0);
|
||||
let projectOptions = $state<{ label: string; value: string }[]>([]);
|
||||
const authToken = GlobalStateStore.getState().session.authToken ?? '';
|
||||
const thoughtOnAPICall = $derived(FormerResolveSpecAPI({
|
||||
authToken,
|
||||
url: '/api/rs/public/thoughts'
|
||||
}));
|
||||
|
||||
onMount(async () => {
|
||||
const projects = await api.projects.list();
|
||||
projectOptions = projects.map((p) => ({ label: p.name, value: p.id }));
|
||||
});
|
||||
|
||||
const menuItems: GridlerContextMenuItem[] = [
|
||||
{ id: 'add', label: 'Add' },
|
||||
{ id: 'edit', label: 'Edit' },
|
||||
{ id: 'edit_content', label: 'Edit Content' },
|
||||
{ id: 'delete', label: 'Delete' },
|
||||
];
|
||||
|
||||
function onRowContextMenu(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
contextRow = rowData ?? null;
|
||||
}
|
||||
|
||||
function normalizeThoughtRecordForFormer(data: Record<string, unknown>): ThoughtForm {
|
||||
return {
|
||||
id: data.id != null ? String(data.id) : undefined,
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
project_id: typeof data.project_id === 'string' && data.project_id ? data.project_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadThoughtFromRow(rowData: Record<string, unknown>): Promise<Thought> {
|
||||
const id = String(rowData[THOUGHT_PRIMARY_KEY] ?? '');
|
||||
return await thoughtOnAPICall('read', 'update', undefined, id) as Thought;
|
||||
}
|
||||
|
||||
async function onMenuItemSelect(item: GridlerContextMenuItem) {
|
||||
if (item.id === 'add') {
|
||||
formValues = { content: '' };
|
||||
formRequest = 'insert';
|
||||
formOpened = true;
|
||||
return;
|
||||
}
|
||||
if (!contextRow) return;
|
||||
if (item.id === 'edit_content') {
|
||||
const thought = normalizeThought(contextRow);
|
||||
selectedThought = thought;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
editorOpened = true;
|
||||
return;
|
||||
}
|
||||
const thought = normalizeThought(contextRow);
|
||||
formValues = {
|
||||
id: thought.id,
|
||||
content: thought.content,
|
||||
project_id: thought.project_id
|
||||
};
|
||||
formRequest = item.id === 'delete' ? 'delete' : 'update';
|
||||
formOpened = true;
|
||||
}
|
||||
|
||||
function normalizeThoughtForm(data: ThoughtForm): Record<string, unknown> {
|
||||
return {
|
||||
content: data.content,
|
||||
project_id: data.project_id || undefined
|
||||
};
|
||||
}
|
||||
|
||||
async function handleThoughtSaved() {
|
||||
formOpened = false;
|
||||
if (contextRow?.id) {
|
||||
const thought = await loadThoughtFromRow(contextRow);
|
||||
await inspectThought(thought);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
|
||||
async function handleThoughtEditorSaved() {
|
||||
editorOpened = false;
|
||||
if (editorValues.id) {
|
||||
const thought = await thoughtOnAPICall('read', 'update', undefined, editorValues.id) as Thought;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
await inspectThought(thought);
|
||||
}
|
||||
refreshKey++;
|
||||
}
|
||||
let gridTotal = $state<number | null>(null);
|
||||
const thoughtsDataSourceOptions = {
|
||||
url: "/api/rs",
|
||||
authToken: GlobalStateStore.getState().session.authToken,
|
||||
schema: "public",
|
||||
entity: "thoughts",
|
||||
uniqueID: "id",
|
||||
hotfields: ["guid", "metadata", "project_id", "archived_at"],
|
||||
uniqueID: THOUGHT_PRIMARY_KEY,
|
||||
hotfields: [THOUGHT_PRIMARY_KEY, "guid", "metadata", "project_id", "archived_at"],
|
||||
sort: [{ column: "created_at", direction: "desc" }],
|
||||
} as unknown as {
|
||||
url: string;
|
||||
@@ -177,6 +281,12 @@
|
||||
void inspectThought(normalizeThought(rowData));
|
||||
}
|
||||
|
||||
function onRowDblClick(_row: number, rowData: Record<string, unknown> | undefined) {
|
||||
if (!rowData) return;
|
||||
contextRow = rowData;
|
||||
void onMenuItemSelect({ id: 'edit', label: 'Edit' });
|
||||
}
|
||||
|
||||
function onGridEvent(
|
||||
type: string,
|
||||
_item?: unknown,
|
||||
@@ -220,6 +330,10 @@
|
||||
/>
|
||||
Archived
|
||||
</label>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => { formValues = { content: '' }; formRequest = 'insert'; formOpened = true; }}>New Thought</button
|
||||
>
|
||||
<button
|
||||
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||
onclick={() => {
|
||||
@@ -235,23 +349,31 @@
|
||||
<p class="text-sm text-rose-300">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={thoughtsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["content"]}
|
||||
filters={baseFilters}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
/>
|
||||
{#key refreshKey}
|
||||
<ErrorBoundary namespace="ThoughtsGridlerFull">
|
||||
<GridlerFull
|
||||
{columns}
|
||||
theme={adminGridTheme}
|
||||
rowMarkers="number"
|
||||
height={560}
|
||||
width="100%"
|
||||
pageSize={40}
|
||||
dataSource="resolvespec"
|
||||
dataSourceOptions={thoughtsDataSourceOptions}
|
||||
serverSideSearch={true}
|
||||
searchColumns={["content"]}
|
||||
filters={baseFilters}
|
||||
{menuItems}
|
||||
{onGridEvent}
|
||||
{onRowClick}
|
||||
{onRowDblClick}
|
||||
{onRowContextMenu}
|
||||
{onMenuItemSelect}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
@@ -259,6 +381,15 @@
|
||||
<h3 class="text-sm font-semibold text-white">Thought Inspector</h3>
|
||||
{#if selectedThought}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="text-xs text-cyan-300 hover:text-cyan-200"
|
||||
onclick={() => {
|
||||
const thought = selectedThought;
|
||||
if (!thought) return;
|
||||
editorValues = { id: thought.id, content: thought.content };
|
||||
editorOpened = true;
|
||||
}}>Edit Content</button
|
||||
>
|
||||
{#if !isSelectedArchived()}
|
||||
<button
|
||||
class="text-xs text-slate-300 hover:text-white"
|
||||
@@ -387,3 +518,60 @@
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBoundary namespace="ThoughtsEditorFormer">
|
||||
<FormerShell
|
||||
bind:opened={editorOpened}
|
||||
bind:values={editorValues}
|
||||
request="update"
|
||||
title="Edit Thought"
|
||||
uniqueKeyField={THOUGHT_PRIMARY_KEY}
|
||||
width="min(96vw, 90rem)"
|
||||
onAPICall={thoughtOnAPICall}
|
||||
beforeSave={(data) => ({ content: data.content })}
|
||||
afterSave={handleThoughtEditorSaved}
|
||||
onClose={() => { editorOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<ContentEditorField
|
||||
filename="thought.md"
|
||||
value={state.values?.content ?? ''}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary namespace="ThoughtsFormer">
|
||||
<FormerShell
|
||||
bind:opened={formOpened}
|
||||
bind:values={formValues}
|
||||
bind:request={formRequest}
|
||||
title={formRequest === 'insert' ? 'New Thought' : formRequest === 'update' ? 'Edit Thought' : 'Delete Thought'}
|
||||
uniqueKeyField={THOUGHT_PRIMARY_KEY}
|
||||
onAPICall={thoughtOnAPICall}
|
||||
afterGet={async (data) => normalizeThoughtRecordForFormer(data as Record<string, unknown>)}
|
||||
beforeSave={normalizeThoughtForm}
|
||||
afterSave={handleThoughtSaved}
|
||||
onClose={() => { formOpened = false; }}
|
||||
>
|
||||
{#snippet children(state)}
|
||||
<div class="space-y-4 p-4">
|
||||
<ContentEditorField
|
||||
filename="thought.md"
|
||||
value={state.values?.content ?? ''}
|
||||
disabled={state.request === 'delete'}
|
||||
onchange={(v) => state.setState('values', { ...state.values, content: v })}
|
||||
/>
|
||||
<NativeSelectCtrl
|
||||
label="Project"
|
||||
name="project_id"
|
||||
disabled={state.request === 'delete'}
|
||||
value={state.values?.project_id ?? ''}
|
||||
options={[{ label: '— None —', value: '' }, ...projectOptions]}
|
||||
onchange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FormerShell>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -37,6 +37,18 @@ export type StatusResponse = {
|
||||
metrics: AccessMetrics;
|
||||
};
|
||||
|
||||
export type PublicStatusClient = {
|
||||
key_id: string;
|
||||
request_count: number;
|
||||
last_accessed_at: string;
|
||||
};
|
||||
|
||||
export type PublicStatusResponse = {
|
||||
connected_count: number;
|
||||
connected_window: string;
|
||||
entries: PublicStatusClient[];
|
||||
};
|
||||
|
||||
export type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -44,7 +56,7 @@ export type NavItem = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'skills' | 'guardrails' | 'files' | 'maintenance';
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'files' | 'maintenance';
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -169,6 +181,24 @@ export type Learning = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'draft' | 'active' | 'blocked' | 'completed' | 'cancelled' | 'superseded';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
project_id?: string;
|
||||
owner?: string;
|
||||
due_date?: string;
|
||||
completed_at?: string;
|
||||
reviewed_by?: string;
|
||||
last_reviewed_at?: string;
|
||||
supersedes_plan_id?: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type MaintenanceTask = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': backendTarget,
|
||||
'/status': backendTarget,
|
||||
'/healthz': backendTarget,
|
||||
'/readyz': backendTarget,
|
||||
'/llm': backendTarget,
|
||||
|
||||
Reference in New Issue
Block a user