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
This commit is contained in:
@@ -142,7 +142,13 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P
|
||||
Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects),
|
||||
Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects),
|
||||
Links: tools.NewLinksTool(db, provider, cfg.Search),
|
||||
Backfill: tools.NewBackfillTool(db, provider, activeProjects, logger),
|
||||
Backfill: tools.NewBackfillTool(db, provider, activeProjects, logger),
|
||||
Reparse: tools.NewReparseMetadataTool(db, provider, cfg.Capture, activeProjects, logger),
|
||||
Household: tools.NewHouseholdTool(db),
|
||||
Maintenance: tools.NewMaintenanceTool(db),
|
||||
Calendar: tools.NewCalendarTool(db),
|
||||
Meals: tools.NewMealsTool(db),
|
||||
CRM: tools.NewCRMTool(db),
|
||||
}
|
||||
|
||||
mcpHandler := mcpserver.New(cfg.MCP, toolSet)
|
||||
|
||||
@@ -24,7 +24,13 @@ type ToolSet struct {
|
||||
Recall *tools.RecallTool
|
||||
Summarize *tools.SummarizeTool
|
||||
Links *tools.LinksTool
|
||||
Backfill *tools.BackfillTool
|
||||
Backfill *tools.BackfillTool
|
||||
Reparse *tools.ReparseMetadataTool
|
||||
Household *tools.HouseholdTool
|
||||
Maintenance *tools.MaintenanceTool
|
||||
Calendar *tools.CalendarTool
|
||||
Meals *tools.MealsTool
|
||||
CRM *tools.CRMTool
|
||||
}
|
||||
|
||||
func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler {
|
||||
@@ -123,6 +129,161 @@ func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler {
|
||||
Description: "Generate missing embeddings for stored thoughts using the active embedding model.",
|
||||
}, toolSet.Backfill.Handle)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "reparse_thought_metadata",
|
||||
Description: "Re-extract and normalize metadata for stored thoughts from their content.",
|
||||
}, toolSet.Reparse.Handle)
|
||||
|
||||
// Household Knowledge
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_household_item",
|
||||
Description: "Store a household fact (paint color, appliance details, measurement, document, etc.).",
|
||||
}, toolSet.Household.AddItem)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "search_household_items",
|
||||
Description: "Search household items by name, category, or location.",
|
||||
}, toolSet.Household.SearchItems)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_household_item",
|
||||
Description: "Retrieve a household item by id.",
|
||||
}, toolSet.Household.GetItem)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_vendor",
|
||||
Description: "Add a service provider (plumber, electrician, landscaper, etc.).",
|
||||
}, toolSet.Household.AddVendor)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "list_vendors",
|
||||
Description: "List household service vendors, optionally filtered by service type.",
|
||||
}, toolSet.Household.ListVendors)
|
||||
|
||||
// Home Maintenance
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_maintenance_task",
|
||||
Description: "Create a recurring or one-time home maintenance task.",
|
||||
}, toolSet.Maintenance.AddTask)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "log_maintenance",
|
||||
Description: "Log completed maintenance work; automatically updates the task's next due date.",
|
||||
}, toolSet.Maintenance.LogWork)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_upcoming_maintenance",
|
||||
Description: "List maintenance tasks due within the next N days.",
|
||||
}, toolSet.Maintenance.GetUpcoming)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "search_maintenance_history",
|
||||
Description: "Search the maintenance log by task name, category, or date range.",
|
||||
}, toolSet.Maintenance.SearchHistory)
|
||||
|
||||
// Family Calendar
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_family_member",
|
||||
Description: "Add a family member to the household.",
|
||||
}, toolSet.Calendar.AddMember)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "list_family_members",
|
||||
Description: "List all family members.",
|
||||
}, toolSet.Calendar.ListMembers)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_activity",
|
||||
Description: "Schedule a one-time or recurring family activity.",
|
||||
}, toolSet.Calendar.AddActivity)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_week_schedule",
|
||||
Description: "Get all activities scheduled for a given week.",
|
||||
}, toolSet.Calendar.GetWeekSchedule)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "search_activities",
|
||||
Description: "Search activities by title, type, or family member.",
|
||||
}, toolSet.Calendar.SearchActivities)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_important_date",
|
||||
Description: "Track a birthday, anniversary, deadline, or other important date.",
|
||||
}, toolSet.Calendar.AddImportantDate)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_upcoming_dates",
|
||||
Description: "Get important dates coming up in the next N days.",
|
||||
}, toolSet.Calendar.GetUpcomingDates)
|
||||
|
||||
// Meal Planning
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_recipe",
|
||||
Description: "Save a recipe with ingredients and instructions.",
|
||||
}, toolSet.Meals.AddRecipe)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "search_recipes",
|
||||
Description: "Search recipes by name, cuisine, tags, or ingredient.",
|
||||
}, toolSet.Meals.SearchRecipes)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "update_recipe",
|
||||
Description: "Update an existing recipe.",
|
||||
}, toolSet.Meals.UpdateRecipe)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "create_meal_plan",
|
||||
Description: "Set the meal plan for a week; replaces any existing plan for that week.",
|
||||
}, toolSet.Meals.CreateMealPlan)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_meal_plan",
|
||||
Description: "Get the meal plan for a given week.",
|
||||
}, toolSet.Meals.GetMealPlan)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "generate_shopping_list",
|
||||
Description: "Auto-generate a shopping list from the meal plan for a given week.",
|
||||
}, toolSet.Meals.GenerateShoppingList)
|
||||
|
||||
// Professional CRM
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "add_professional_contact",
|
||||
Description: "Add a professional contact to the CRM.",
|
||||
}, toolSet.CRM.AddContact)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "search_contacts",
|
||||
Description: "Search professional contacts by name, company, title, notes, or tags.",
|
||||
}, toolSet.CRM.SearchContacts)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "log_interaction",
|
||||
Description: "Log an interaction with a professional contact.",
|
||||
}, toolSet.CRM.LogInteraction)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_contact_history",
|
||||
Description: "Get full history (interactions and opportunities) for a contact.",
|
||||
}, toolSet.CRM.GetHistory)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "create_opportunity",
|
||||
Description: "Create a deal, project, or opportunity linked to a contact.",
|
||||
}, toolSet.CRM.CreateOpportunity)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "get_follow_ups_due",
|
||||
Description: "List contacts with a follow-up date due within the next N days.",
|
||||
}, toolSet.CRM.GetFollowUpsDue)
|
||||
|
||||
addTool(server, &mcp.Tool{
|
||||
Name: "link_thought_to_contact",
|
||||
Description: "Append a stored thought to a contact's notes.",
|
||||
}, toolSet.CRM.LinkThought)
|
||||
|
||||
return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return server
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
|
||||
210
internal/store/calendar.go
Normal file
210
internal/store/calendar.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
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))
|
||||
|
||||
created := m
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||
}
|
||||
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()
|
||||
|
||||
var members []ext.FamilyMember
|
||||
for rows.Next() {
|
||||
var m ext.FamilyMember
|
||||
var relationship, notes *string
|
||||
if err := rows.Scan(&m.ID, &m.Name, &relationship, &m.BirthDate, ¬es, &m.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan family member: %w", err)
|
||||
}
|
||||
m.Relationship = strVal(relationship)
|
||||
m.Notes = strVal(notes)
|
||||
members = append(members, m)
|
||||
}
|
||||
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))
|
||||
|
||||
created := a
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return scanActivities(rows)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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))
|
||||
|
||||
created := d
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||
}
|
||||
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)
|
||||
|
||||
// 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 d ext.ImportantDate
|
||||
var memberID *uuid.UUID
|
||||
var memberName, notes *string
|
||||
if err := rows.Scan(&d.ID, &memberID, &memberName, &d.Title, &d.DateValue,
|
||||
&d.RecurringYearly, &d.ReminderDaysBefore, ¬es, &d.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan important date: %w", err)
|
||||
}
|
||||
d.FamilyMemberID = memberID
|
||||
d.MemberName = strVal(memberName)
|
||||
d.Notes = strVal(notes)
|
||||
dates = append(dates, d)
|
||||
}
|
||||
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 a ext.Activity
|
||||
var memberName, activityType, dayOfWeek, startTime, endTime, location, notes *string
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.FamilyMemberID, &memberName, &a.Title, &activityType,
|
||||
&dayOfWeek, &startTime, &endTime,
|
||||
&a.StartDate, &a.EndDate, &location, ¬es, &a.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan activity: %w", err)
|
||||
}
|
||||
a.MemberName = strVal(memberName)
|
||||
a.ActivityType = strVal(activityType)
|
||||
a.DayOfWeek = strVal(dayOfWeek)
|
||||
a.StartTime = strVal(startTime)
|
||||
a.EndTime = strVal(endTime)
|
||||
a.Location = strVal(location)
|
||||
a.Notes = strVal(notes)
|
||||
activities = append(activities, a)
|
||||
}
|
||||
return activities, rows.Err()
|
||||
}
|
||||
247
internal/store/crm.go
Normal file
247
internal/store/crm.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
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
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, 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()
|
||||
|
||||
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, notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
from professional_contacts where id = $1
|
||||
`, id)
|
||||
|
||||
var c ext.ProfessionalContact
|
||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||
if err := row.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||
}
|
||||
c.Company = strVal(company)
|
||||
c.Title = strVal(title)
|
||||
c.Email = strVal(email)
|
||||
c.Phone = strVal(phone)
|
||||
c.LinkedInURL = strVal(linkedInURL)
|
||||
c.HowWeMet = strVal(howWeMet)
|
||||
c.Notes = strVal(notes)
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 i ext.ContactInteraction
|
||||
var followUpNotes *string
|
||||
if err := rows.Scan(&i.ID, &i.ContactID, &i.InteractionType, &i.OccurredAt, &i.Summary,
|
||||
&i.FollowUpNeeded, &followUpNotes, &i.CreatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||
}
|
||||
i.FollowUpNotes = strVal(followUpNotes)
|
||||
interactions = append(interactions, i)
|
||||
}
|
||||
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()
|
||||
|
||||
var opportunities []ext.Opportunity
|
||||
for oppRows.Next() {
|
||||
var o ext.Opportunity
|
||||
var description, notes *string
|
||||
if err := oppRows.Scan(&o.ID, &o.ContactID, &o.Title, &description, &o.Stage, &o.Value,
|
||||
&o.ExpectedCloseDate, ¬es, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||
}
|
||||
o.Description = strVal(description)
|
||||
o.Notes = strVal(notes)
|
||||
opportunities = append(opportunities, o)
|
||||
}
|
||||
if err := oppRows.Err(); err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
created := o
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||
}
|
||||
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)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, 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)
|
||||
}
|
||||
|
||||
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 c ext.ProfessionalContact
|
||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan contact: %w", err)
|
||||
}
|
||||
c.Company = strVal(company)
|
||||
c.Title = strVal(title)
|
||||
c.Email = strVal(email)
|
||||
c.Phone = strVal(phone)
|
||||
c.LinkedInURL = strVal(linkedInURL)
|
||||
c.HowWeMet = strVal(howWeMet)
|
||||
c.Notes = strVal(notes)
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, rows.Err()
|
||||
}
|
||||
15
internal/store/helpers.go
Normal file
15
internal/store/helpers.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package store
|
||||
|
||||
func nullStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func strVal(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
150
internal/store/household.go
Normal file
150
internal/store/household.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
var items []ext.HouseholdItem
|
||||
for rows.Next() {
|
||||
var item ext.HouseholdItem
|
||||
var detailsBytes []byte
|
||||
var category, location, notes *string
|
||||
if err := rows.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan household item: %w", err)
|
||||
}
|
||||
item.Category = strVal(category)
|
||||
item.Location = strVal(location)
|
||||
item.Notes = strVal(notes)
|
||||
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||
item.Details = map[string]any{}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
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)
|
||||
|
||||
var item ext.HouseholdItem
|
||||
var detailsBytes []byte
|
||||
var category, location, notes *string
|
||||
if err := row.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
|
||||
}
|
||||
item.Category = strVal(category)
|
||||
item.Location = strVal(location)
|
||||
item.Notes = strVal(notes)
|
||||
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||
item.Details = map[string]any{}
|
||||
}
|
||||
return item, 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)
|
||||
|
||||
created := v
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
|
||||
}
|
||||
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"
|
||||
|
||||
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 v ext.HouseholdVendor
|
||||
var serviceType, phone, email, website, notes *string
|
||||
var lastUsed *time.Time
|
||||
if err := rows.Scan(&v.ID, &v.Name, &serviceType, &phone, &email, &website, ¬es, &v.Rating, &lastUsed, &v.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan vendor: %w", err)
|
||||
}
|
||||
v.ServiceType = strVal(serviceType)
|
||||
v.Phone = strVal(phone)
|
||||
v.Email = strVal(email)
|
||||
v.Website = strVal(website)
|
||||
v.Notes = strVal(notes)
|
||||
v.LastUsed = lastUsed
|
||||
vendors = append(vendors, v)
|
||||
}
|
||||
return vendors, rows.Err()
|
||||
}
|
||||
142
internal/store/maintenance.go
Normal file
142
internal/store/maintenance.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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))
|
||||
|
||||
created := t
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
if err := row.Scan(&created.ID); err != nil {
|
||||
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
|
||||
}
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
return scanMaintenanceTasks(rows)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
var logs []ext.MaintenanceLogWithTask
|
||||
for rows.Next() {
|
||||
var l ext.MaintenanceLogWithTask
|
||||
var performedBy, notes, nextAction, taskCategory *string
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.TaskID, &l.CompletedAt, &performedBy, &l.Cost, ¬es, &nextAction,
|
||||
&l.TaskName, &taskCategory,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance log: %w", err)
|
||||
}
|
||||
l.PerformedBy = strVal(performedBy)
|
||||
l.Notes = strVal(notes)
|
||||
l.NextAction = strVal(nextAction)
|
||||
l.TaskCategory = strVal(taskCategory)
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
func scanMaintenanceTasks(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.MaintenanceTask, error) {
|
||||
defer rows.Close()
|
||||
var tasks []ext.MaintenanceTask
|
||||
for rows.Next() {
|
||||
var t ext.MaintenanceTask
|
||||
var category, notes *string
|
||||
if err := rows.Scan(&t.ID, &t.Name, &category, &t.FrequencyDays, &t.LastCompleted, &t.NextDue, &t.Priority, ¬es, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance task: %w", err)
|
||||
}
|
||||
t.Category = strVal(category)
|
||||
t.Notes = strVal(notes)
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
289
internal/store/meals.go
Normal file
289
internal/store/meals.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
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{}
|
||||
}
|
||||
|
||||
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
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, 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()
|
||||
|
||||
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, rating, notes, created_at, updated_at
|
||||
from recipes where id = $1
|
||||
`, id)
|
||||
|
||||
var r ext.Recipe
|
||||
var cuisine, notes *string
|
||||
var ingredientsBytes, instructionsBytes []byte
|
||||
if err := row.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
|
||||
}
|
||||
r.Cuisine = strVal(cuisine)
|
||||
r.Notes = strVal(notes)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||
r.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||
r.Instructions = []string{}
|
||||
}
|
||||
return r, 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{}
|
||||
}
|
||||
|
||||
_, 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)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if err := row.Scan(&entry.ID, &entry.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("insert meal plan entry: %w", err)
|
||||
}
|
||||
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()
|
||||
|
||||
var entries []ext.MealPlanEntry
|
||||
for rows.Next() {
|
||||
var e ext.MealPlanEntry
|
||||
var recipeName, customMeal, notes *string
|
||||
if err := rows.Scan(&e.ID, &e.WeekStart, &e.DayOfWeek, &e.MealType, &e.RecipeID, &recipeName, &customMeal, &e.Servings, ¬es, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan meal plan entry: %w", err)
|
||||
}
|
||||
e.RecipeName = strVal(recipeName)
|
||||
e.CustomMeal = strVal(customMeal)
|
||||
e.Notes = strVal(notes)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
|
||||
if err := row.Scan(&list.ID, &list.CreatedAt, &list.UpdatedAt); err != nil {
|
||||
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
|
||||
var r ext.Recipe
|
||||
var cuisine, notes *string
|
||||
var ingredientsBytes, instructionsBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
r.Cuisine = strVal(cuisine)
|
||||
r.Notes = strVal(notes)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||
r.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||
r.Instructions = []string{}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -428,6 +428,57 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
|
||||
return thoughts, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *uuid.UUID, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||
args := make([]any, 0, 3)
|
||||
conditions := make([]string, 0, 4)
|
||||
|
||||
if !includeArchived {
|
||||
conditions = append(conditions, "archived_at is null")
|
||||
}
|
||||
if projectID != nil {
|
||||
args = append(args, *projectID)
|
||||
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
|
||||
}
|
||||
if olderThanDays > 0 {
|
||||
args = append(args, time.Now().Add(-time.Duration(olderThanDays)*24*time.Hour))
|
||||
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
query := `
|
||||
select guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||
from thoughts
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
query += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
query += " order by created_at asc limit $" + fmt.Sprintf("%d", len(args))
|
||||
|
||||
rows, err := db.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list thoughts for metadata reparse: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
thoughts := make([]thoughttypes.Thought, 0, limit)
|
||||
for rows.Next() {
|
||||
var thought thoughttypes.Thought
|
||||
var metadataBytes []byte
|
||||
if err := rows.Scan(&thought.ID, &thought.Content, &metadataBytes, &thought.ProjectID, &thought.ArchivedAt, &thought.CreatedAt, &thought.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan metadata-reparse thought: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(metadataBytes, &thought.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("decode metadata-reparse thought metadata: %w", err)
|
||||
}
|
||||
thoughts = append(thoughts, thought)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate metadata-reparse thoughts: %w", err)
|
||||
}
|
||||
|
||||
return thoughts, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into embeddings (thought_id, model, dim, embedding)
|
||||
|
||||
212
internal/tools/calendar.go
Normal file
212
internal/tools/calendar.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 CalendarTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
|
||||
func NewCalendarTool(db *store.DB) *CalendarTool {
|
||||
return &CalendarTool{store: db}
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
type ListFamilyMembersInput struct{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("title is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, AddImportantDateOutput{}, errInvalidInput("title is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
232
internal/tools/crm.go
Normal file
232
internal/tools/crm.go
Normal file
@@ -0,0 +1,232 @@
|
||||
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
|
||||
}
|
||||
151
internal/tools/household.go
Normal file
151
internal/tools/household.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"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 HouseholdTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
|
||||
func NewHouseholdTool(db *store.DB) *HouseholdTool {
|
||||
return &HouseholdTool{store: db}
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
type GetHouseholdItemInput struct {
|
||||
ID uuid.UUID `json:"id" jsonschema:"item id"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
type ListVendorsInput struct {
|
||||
ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
137
internal/tools/maintenance.go
Normal file
137
internal/tools/maintenance.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 MaintenanceTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
|
||||
func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
|
||||
return &MaintenanceTool{store: db}
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
210
internal/tools/meals.go
Normal file
210
internal/tools/meals.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 MealsTool struct {
|
||||
store *store.DB
|
||||
}
|
||||
|
||||
func NewMealsTool(db *store.DB) *MealsTool {
|
||||
return &MealsTool{store: db}
|
||||
}
|
||||
|
||||
// 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 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{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return nil, UpdateRecipeOutput{}, errInvalidInput("name is required")
|
||||
}
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
type GetMealPlanInput struct {
|
||||
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// generate_shopping_list
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
163
internal/tools/reparse_metadata.go
Normal file
163
internal/tools/reparse_metadata.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/ai"
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
"git.warky.dev/wdevs/amcs/internal/metadata"
|
||||
"git.warky.dev/wdevs/amcs/internal/session"
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
const metadataReparseConcurrency = 4
|
||||
|
||||
type ReparseMetadataTool struct {
|
||||
store *store.DB
|
||||
provider ai.Provider
|
||||
capture config.CaptureConfig
|
||||
sessions *session.ActiveProjects
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type ReparseMetadataInput struct {
|
||||
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope the reparse"`
|
||||
Limit int `json:"limit,omitempty" jsonschema:"maximum number of thoughts to process in one call; defaults to 100"`
|
||||
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"whether to include archived thoughts; defaults to false"`
|
||||
OlderThanDays int `json:"older_than_days,omitempty" jsonschema:"only reparse thoughts older than N days; 0 means no restriction"`
|
||||
DryRun bool `json:"dry_run,omitempty" jsonschema:"report counts without updating metadata"`
|
||||
}
|
||||
|
||||
type ReparseMetadataFailure struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ReparseMetadataOutput struct {
|
||||
Scanned int `json:"scanned"`
|
||||
Reparsed int `json:"reparsed"`
|
||||
Normalized int `json:"normalized"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Failures []ReparseMetadataFailure `json:"failures,omitempty"`
|
||||
}
|
||||
|
||||
func NewReparseMetadataTool(db *store.DB, provider ai.Provider, capture config.CaptureConfig, sessions *session.ActiveProjects, logger *slog.Logger) *ReparseMetadataTool {
|
||||
return &ReparseMetadataTool{store: db, provider: provider, capture: capture, sessions: sessions, logger: logger}
|
||||
}
|
||||
|
||||
func (t *ReparseMetadataTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in ReparseMetadataInput) (*mcp.CallToolResult, ReparseMetadataOutput, error) {
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, ReparseMetadataOutput{}, err
|
||||
}
|
||||
|
||||
var projectID *uuid.UUID
|
||||
if project != nil {
|
||||
projectID = &project.ID
|
||||
}
|
||||
|
||||
thoughts, err := t.store.ListThoughtsForMetadataReparse(ctx, limit, projectID, in.IncludeArchived, in.OlderThanDays)
|
||||
if err != nil {
|
||||
return nil, ReparseMetadataOutput{}, err
|
||||
}
|
||||
|
||||
out := ReparseMetadataOutput{
|
||||
Scanned: len(thoughts),
|
||||
DryRun: in.DryRun,
|
||||
}
|
||||
|
||||
if in.DryRun || len(thoughts) == 0 {
|
||||
return nil, out, nil
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
sem := semaphore.NewWeighted(metadataReparseConcurrency)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, thought := range thoughts {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if err := sem.Acquire(ctx, 1); err != nil {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(thought thoughttypes.Thought) {
|
||||
defer wg.Done()
|
||||
defer sem.Release(1)
|
||||
|
||||
normalizedCurrent := metadata.Normalize(thought.Metadata, t.capture)
|
||||
|
||||
extracted, extractErr := t.provider.ExtractMetadata(ctx, thought.Content)
|
||||
normalizedTarget := normalizedCurrent
|
||||
if extractErr != nil {
|
||||
mu.Lock()
|
||||
out.Normalized++
|
||||
mu.Unlock()
|
||||
t.logger.Warn("metadata reparse extract failed, using normalized existing metadata", slog.String("thought_id", thought.ID.String()), slog.String("error", extractErr.Error()))
|
||||
} else {
|
||||
normalizedTarget = metadata.Normalize(extracted, t.capture)
|
||||
mu.Lock()
|
||||
out.Reparsed++
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
if metadataEqual(thought.Metadata, normalizedTarget) {
|
||||
mu.Lock()
|
||||
out.Skipped++
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if _, updateErr := t.store.UpdateThought(ctx, thought.ID, thought.Content, nil, "", normalizedTarget, thought.ProjectID); updateErr != nil {
|
||||
mu.Lock()
|
||||
out.Failures = append(out.Failures, ReparseMetadataFailure{ID: thought.ID.String(), Error: updateErr.Error()})
|
||||
mu.Unlock()
|
||||
t.logger.Warn("metadata reparse update failed", slog.String("thought_id", thought.ID.String()), slog.String("error", updateErr.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
out.Updated++
|
||||
mu.Unlock()
|
||||
}(thought)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
out.Failed = len(out.Failures)
|
||||
|
||||
t.logger.Info("metadata reparse completed",
|
||||
slog.Int("scanned", out.Scanned),
|
||||
slog.Int("reparsed", out.Reparsed),
|
||||
slog.Int("normalized", out.Normalized),
|
||||
slog.Int("updated", out.Updated),
|
||||
slog.Int("skipped", out.Skipped),
|
||||
slog.Int("failed", out.Failed),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
)
|
||||
|
||||
return nil, out, nil
|
||||
}
|
||||
|
||||
func metadataEqual(a, b thoughttypes.ThoughtMetadata) bool {
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
215
internal/types/extensions.go
Normal file
215
internal/types/extensions.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Household Knowledge
|
||||
|
||||
type HouseholdItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Details map[string]any `json:"details"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HouseholdVendor struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ServiceType string `json:"service_type,omitempty"`
|
||||
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"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Home Maintenance
|
||||
|
||||
type MaintenanceTask struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category,omitempty"`
|
||||
FrequencyDays *int `json:"frequency_days,omitempty"`
|
||||
LastCompleted *time.Time `json:"last_completed,omitempty"`
|
||||
NextDue *time.Time `json:"next_due,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MaintenanceLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PerformedBy string `json:"performed_by,omitempty"`
|
||||
Cost *float64 `json:"cost,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NextAction string `json:"next_action,omitempty"`
|
||||
}
|
||||
|
||||
type MaintenanceLogWithTask struct {
|
||||
MaintenanceLog
|
||||
TaskName string `json:"task_name"`
|
||||
TaskCategory string `json:"task_category,omitempty"`
|
||||
}
|
||||
|
||||
// Family Calendar
|
||||
|
||||
type FamilyMember struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Relationship string `json:"relationship,omitempty"`
|
||||
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||
MemberName string `json:"member_name,omitempty"`
|
||||
Title string `json:"title"`
|
||||
ActivityType string `json:"activity_type,omitempty"`
|
||||
DayOfWeek string `json:"day_of_week,omitempty"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ImportantDate struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||
MemberName string `json:"member_name,omitempty"`
|
||||
Title string `json:"title"`
|
||||
DateValue time.Time `json:"date_value"`
|
||||
RecurringYearly bool `json:"recurring_yearly"`
|
||||
ReminderDaysBefore int `json:"reminder_days_before"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Meal Planning
|
||||
|
||||
type Ingredient struct {
|
||||
Name string `json:"name"`
|
||||
Quantity string `json:"quantity,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
type Recipe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"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 []Ingredient `json:"ingredients"`
|
||||
Instructions []string `json:"instructions"`
|
||||
Tags []string `json:"tags"`
|
||||
Rating *int `json:"rating,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MealPlanEntry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WeekStart time.Time `json:"week_start"`
|
||||
DayOfWeek string `json:"day_of_week"`
|
||||
MealType string `json:"meal_type"`
|
||||
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
|
||||
RecipeName string `json:"recipe_name,omitempty"`
|
||||
CustomMeal string `json:"custom_meal,omitempty"`
|
||||
Servings *int `json:"servings,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type MealPlanInput struct {
|
||||
DayOfWeek string `json:"day_of_week" jsonschema:"day of week (monday-sunday)"`
|
||||
MealType string `json:"meal_type" jsonschema:"one of: breakfast, lunch, dinner, snack"`
|
||||
RecipeID *uuid.UUID `json:"recipe_id,omitempty" jsonschema:"optional recipe id"`
|
||||
CustomMeal string `json:"custom_meal,omitempty" jsonschema:"optional free-text meal description when no recipe"`
|
||||
Servings *int `json:"servings,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
Name string `json:"name"`
|
||||
Quantity string `json:"quantity,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Purchased bool `json:"purchased"`
|
||||
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
|
||||
}
|
||||
|
||||
type ShoppingList struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WeekStart time.Time `json:"week_start"`
|
||||
Items []ShoppingItem `json:"items"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Professional CRM
|
||||
|
||||
type ProfessionalContact struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
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"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
LastContacted *time.Time `json:"last_contacted,omitempty"`
|
||||
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ContactInteraction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ContactID uuid.UUID `json:"contact_id"`
|
||||
InteractionType string `json:"interaction_type"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Summary string `json:"summary"`
|
||||
FollowUpNeeded bool `json:"follow_up_needed"`
|
||||
FollowUpNotes string `json:"follow_up_notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Opportunity struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Stage string `json:"stage"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ContactHistory struct {
|
||||
Contact ProfessionalContact `json:"contact"`
|
||||
Interactions []ContactInteraction `json:"interactions"`
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
}
|
||||
Reference in New Issue
Block a user