Files
amcs/internal/tools/crm.go
Hein 0eb6ac7ee5 feat(tools): add maintenance and meal planning tools with CRUD operations
- Implement maintenance tool for adding, logging, and retrieving tasks
- Create meals tool for managing recipes, meal plans, and shopping lists
- Introduce reparse metadata tool for updating thought metadata
- Add household knowledge, home maintenance, family calendar, meal planning, and professional CRM database migrations
- Grant necessary permissions for new database tables
2026-03-26 23:29:03 +02:00

233 lines
7.9 KiB
Go

package tools
import (
"context"
"fmt"
"strings"
"time"
"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"
)
type CRMTool struct {
store *store.DB
}
func NewCRMTool(db *store.DB) *CRMTool {
return &CRMTool{store: db}
}
// 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 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{}, errInvalidInput("name is required")
}
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
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"`
}
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
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"`
}
func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
if strings.TrimSpace(in.Summary) == "" {
return nil, LogInteractionOutput{}, errInvalidInput("summary is required")
}
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
type GetContactHistoryInput struct {
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
}
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
}
// 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 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{}, errInvalidInput("title is required")
}
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
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"`
}
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
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"`
}
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 {
return nil, LinkThoughtToContactOutput{}, fmt.Errorf("thought not found: %w", 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 {
return nil, LinkThoughtToContactOutput{}, err
}
return nil, LinkThoughtToContactOutput{Contact: contact}, nil
}