diff --git a/.gitignore b/.gitignore index 8d82007..60bf563 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ go.work.sum # local config configs/*.local.yaml cmd/amcs-server/__debug_* -bin/ \ No newline at end of file +bin/ +OB1/ \ No newline at end of file diff --git a/README.md b/README.md index 540c174..6e46a85 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte | `link_thoughts` | Create a typed relationship between thoughts | | `related_thoughts` | Explicit links + semantic neighbours | | `backfill_embeddings` | Generate missing embeddings for stored thoughts | +| `reparse_thought_metadata` | Re-extract and normalize metadata for stored thoughts | ## Configuration @@ -93,6 +94,24 @@ Run `backfill_embeddings` after switching embedding models or importing thoughts - `limit` — max thoughts per call (default 100) - Embeddings are generated in parallel (4 workers) and upserted; one failure does not abort the run +## Metadata Reparse + +Run `reparse_thought_metadata` to fix stale or inconsistent metadata by re-extracting it from thought content. + +```json +{ + "project": "optional-project-name", + "limit": 100, + "include_archived": false, + "older_than_days": 0, + "dry_run": false +} +``` + +- `dry_run: true` scans only and does not call metadata extraction or write updates +- If extraction fails for a thought, existing metadata is normalized and written only if it changes +- Metadata reparse runs in parallel (4 workers); one failure does not abort the run + **Automatic backfill** (optional, config-gated): ```yaml diff --git a/internal/app/app.go b/internal/app/app.go index 7928e94..8a76186 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 4be4296..e30f137 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -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{ diff --git a/internal/store/calendar.go b/internal/store/calendar.go new file mode 100644 index 0000000..c641f5c --- /dev/null +++ b/internal/store/calendar.go @@ -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() +} diff --git a/internal/store/crm.go b/internal/store/crm.go new file mode 100644 index 0000000..948f74a --- /dev/null +++ b/internal/store/crm.go @@ -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() +} diff --git a/internal/store/helpers.go b/internal/store/helpers.go new file mode 100644 index 0000000..5488dc0 --- /dev/null +++ b/internal/store/helpers.go @@ -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 +} diff --git a/internal/store/household.go b/internal/store/household.go new file mode 100644 index 0000000..16eeaf1 --- /dev/null +++ b/internal/store/household.go @@ -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() +} diff --git a/internal/store/maintenance.go b/internal/store/maintenance.go new file mode 100644 index 0000000..5e02c3a --- /dev/null +++ b/internal/store/maintenance.go @@ -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() +} diff --git a/internal/store/meals.go b/internal/store/meals.go new file mode 100644 index 0000000..98475f9 --- /dev/null +++ b/internal/store/meals.go @@ -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 +} diff --git a/internal/store/thoughts.go b/internal/store/thoughts.go index 42f60b8..af2a562 100644 --- a/internal/store/thoughts.go +++ b/internal/store/thoughts.go @@ -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) diff --git a/internal/tools/calendar.go b/internal/tools/calendar.go new file mode 100644 index 0000000..089c68e --- /dev/null +++ b/internal/tools/calendar.go @@ -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 +} diff --git a/internal/tools/crm.go b/internal/tools/crm.go new file mode 100644 index 0000000..03f72b4 --- /dev/null +++ b/internal/tools/crm.go @@ -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 +} diff --git a/internal/tools/household.go b/internal/tools/household.go new file mode 100644 index 0000000..75652e3 --- /dev/null +++ b/internal/tools/household.go @@ -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 +} diff --git a/internal/tools/maintenance.go b/internal/tools/maintenance.go new file mode 100644 index 0000000..dd25b53 --- /dev/null +++ b/internal/tools/maintenance.go @@ -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 +} diff --git a/internal/tools/meals.go b/internal/tools/meals.go new file mode 100644 index 0000000..58584a0 --- /dev/null +++ b/internal/tools/meals.go @@ -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 +} diff --git a/internal/tools/reparse_metadata.go b/internal/tools/reparse_metadata.go new file mode 100644 index 0000000..c4edaa7 --- /dev/null +++ b/internal/tools/reparse_metadata.go @@ -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) +} diff --git a/internal/types/extensions.go b/internal/types/extensions.go new file mode 100644 index 0000000..68e4dfb --- /dev/null +++ b/internal/types/extensions.go @@ -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"` +} diff --git a/migrations/009_rls_and_grants.sql b/migrations/009_rls_and_grants.sql deleted file mode 100644 index 2365637..0000000 --- a/migrations/009_rls_and_grants.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Grant these permissions to the database role used by the application. --- Replace amcs_user with the actual role in your deployment before applying. -grant ALL ON TABLE public.thoughts to amcs; -grant ALL ON TABLE public.projects to amcs; -grant ALL ON TABLE public.thought_links to amcs; -grant ALL ON TABLE public.embeddings to amcs; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs; \ No newline at end of file diff --git a/migrations/011_household_knowledge.sql b/migrations/011_household_knowledge.sql new file mode 100644 index 0000000..3ef1628 --- /dev/null +++ b/migrations/011_household_knowledge.sql @@ -0,0 +1,42 @@ +-- Extension 1: Household Knowledge Base +-- Stores household facts and vendor contacts (single-user, no RLS) + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TABLE IF NOT EXISTS household_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + category TEXT, + location TEXT, + details JSONB NOT NULL DEFAULT '{}', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS household_vendors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + service_type TEXT, + phone TEXT, + email TEXT, + website TEXT, + notes TEXT, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + last_used DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_household_items_category ON household_items(category); +CREATE INDEX IF NOT EXISTS idx_household_vendors_service ON household_vendors(service_type); + +DROP TRIGGER IF EXISTS update_household_items_updated_at ON household_items; +CREATE TRIGGER update_household_items_updated_at + BEFORE UPDATE ON household_items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/012_home_maintenance.sql b/migrations/012_home_maintenance.sql new file mode 100644 index 0000000..25df2bf --- /dev/null +++ b/migrations/012_home_maintenance.sql @@ -0,0 +1,56 @@ +-- Extension 2: Home Maintenance Tracker +-- Tracks recurring maintenance tasks and logs completed work (single-user, no RLS) + +CREATE TABLE IF NOT EXISTS maintenance_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + category TEXT, + frequency_days INTEGER, + last_completed TIMESTAMPTZ, + next_due TIMESTAMPTZ, + priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS maintenance_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + task_id UUID NOT NULL REFERENCES maintenance_tasks(id) ON DELETE CASCADE, + completed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + performed_by TEXT, + cost DECIMAL(10, 2), + notes TEXT, + next_action TEXT +); + +CREATE INDEX IF NOT EXISTS idx_maintenance_tasks_next_due ON maintenance_tasks(next_due); +CREATE INDEX IF NOT EXISTS idx_maintenance_logs_task ON maintenance_logs(task_id, completed_at DESC); + +DROP TRIGGER IF EXISTS update_maintenance_tasks_updated_at ON maintenance_tasks; +CREATE TRIGGER update_maintenance_tasks_updated_at + BEFORE UPDATE ON maintenance_tasks + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE OR REPLACE FUNCTION update_task_after_maintenance_log() +RETURNS TRIGGER AS $$ +DECLARE + task_frequency INTEGER; +BEGIN + SELECT frequency_days INTO task_frequency FROM maintenance_tasks WHERE id = NEW.task_id; + UPDATE maintenance_tasks + SET last_completed = NEW.completed_at, + next_due = CASE + WHEN task_frequency IS NOT NULL THEN NEW.completed_at + (task_frequency || ' days')::INTERVAL + ELSE NULL + END, + updated_at = now() + WHERE id = NEW.task_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS update_task_after_log ON maintenance_logs; +CREATE TRIGGER update_task_after_log + AFTER INSERT ON maintenance_logs + FOR EACH ROW EXECUTE FUNCTION update_task_after_maintenance_log(); diff --git a/migrations/013_family_calendar.sql b/migrations/013_family_calendar.sql new file mode 100644 index 0000000..d416739 --- /dev/null +++ b/migrations/013_family_calendar.sql @@ -0,0 +1,42 @@ +-- Extension 3: Family Calendar +-- Multi-person family scheduling (single-user, no RLS) + +CREATE TABLE IF NOT EXISTS family_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + relationship TEXT, + birth_date DATE, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS activities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + family_member_id UUID REFERENCES family_members(id) ON DELETE SET NULL, + title TEXT NOT NULL, + activity_type TEXT, + day_of_week TEXT, + start_time TIME, + end_time TIME, + start_date DATE, + end_date DATE, + location TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS important_dates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + family_member_id UUID REFERENCES family_members(id) ON DELETE SET NULL, + title TEXT NOT NULL, + date_value DATE NOT NULL, + recurring_yearly BOOLEAN NOT NULL DEFAULT false, + reminder_days_before INTEGER NOT NULL DEFAULT 7, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_activities_dow ON activities(day_of_week); +CREATE INDEX IF NOT EXISTS idx_activities_member ON activities(family_member_id); +CREATE INDEX IF NOT EXISTS idx_activities_dates ON activities(start_date, end_date); +CREATE INDEX IF NOT EXISTS idx_important_dates_date ON important_dates(date_value); diff --git a/migrations/014_meal_planning.sql b/migrations/014_meal_planning.sql new file mode 100644 index 0000000..229bab1 --- /dev/null +++ b/migrations/014_meal_planning.sql @@ -0,0 +1,54 @@ +-- Extension 4: Meal Planning +-- Recipes, weekly meal plans, and shopping lists (single-user, no RLS) + +CREATE TABLE IF NOT EXISTS recipes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + cuisine TEXT, + prep_time_minutes INTEGER, + cook_time_minutes INTEGER, + servings INTEGER, + ingredients JSONB NOT NULL DEFAULT '[]', + instructions JSONB NOT NULL DEFAULT '[]', + tags TEXT[] NOT NULL DEFAULT '{}', + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS meal_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + week_start DATE NOT NULL, + day_of_week TEXT NOT NULL, + meal_type TEXT NOT NULL CHECK (meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, + custom_meal TEXT, + servings INTEGER, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS shopping_lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + week_start DATE NOT NULL UNIQUE, + items JSONB NOT NULL DEFAULT '[]', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_recipes_cuisine ON recipes(cuisine); +CREATE INDEX IF NOT EXISTS idx_recipes_tags ON recipes USING GIN (tags); +CREATE INDEX IF NOT EXISTS idx_meal_plans_week ON meal_plans(week_start); +CREATE INDEX IF NOT EXISTS idx_shopping_lists_week ON shopping_lists(week_start); + +DROP TRIGGER IF EXISTS update_recipes_updated_at ON recipes; +CREATE TRIGGER update_recipes_updated_at + BEFORE UPDATE ON recipes + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_shopping_lists_updated_at ON shopping_lists; +CREATE TRIGGER update_shopping_lists_updated_at + BEFORE UPDATE ON shopping_lists + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/015_professional_crm.sql b/migrations/015_professional_crm.sql new file mode 100644 index 0000000..18478a8 --- /dev/null +++ b/migrations/015_professional_crm.sql @@ -0,0 +1,71 @@ +-- Extension 5: Professional CRM +-- Contacts, interaction logs, and opportunities (single-user, no RLS) + +CREATE TABLE IF NOT EXISTS professional_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + company TEXT, + title TEXT, + email TEXT, + phone TEXT, + linkedin_url TEXT, + how_we_met TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + notes TEXT, + last_contacted TIMESTAMPTZ, + follow_up_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS contact_interactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contact_id UUID NOT NULL REFERENCES professional_contacts(id) ON DELETE CASCADE, + interaction_type TEXT NOT NULL CHECK (interaction_type IN ('meeting', 'email', 'call', 'coffee', 'event', 'linkedin', 'other')), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + summary TEXT NOT NULL, + follow_up_needed BOOLEAN NOT NULL DEFAULT false, + follow_up_notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS opportunities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contact_id UUID REFERENCES professional_contacts(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + stage TEXT NOT NULL DEFAULT 'identified' CHECK (stage IN ('identified', 'in_conversation', 'proposal', 'negotiation', 'won', 'lost')), + value DECIMAL(12, 2), + expected_close_date DATE, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_contacts_last_contacted ON professional_contacts(last_contacted); +CREATE INDEX IF NOT EXISTS idx_contacts_follow_up ON professional_contacts(follow_up_date) WHERE follow_up_date IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_interactions_contact ON contact_interactions(contact_id, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_opportunities_stage ON opportunities(stage); + +DROP TRIGGER IF EXISTS update_professional_contacts_updated_at ON professional_contacts; +CREATE TRIGGER update_professional_contacts_updated_at + BEFORE UPDATE ON professional_contacts + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_opportunities_updated_at ON opportunities; +CREATE TRIGGER update_opportunities_updated_at + BEFORE UPDATE ON opportunities + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE OR REPLACE FUNCTION update_last_contacted() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE professional_contacts SET last_contacted = NEW.occurred_at WHERE id = NEW.contact_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS update_contact_last_contacted ON contact_interactions; +CREATE TRIGGER update_contact_last_contacted + AFTER INSERT ON contact_interactions + FOR EACH ROW EXECUTE FUNCTION update_last_contacted(); diff --git a/migrations/100_rls_and_grants.sql b/migrations/100_rls_and_grants.sql new file mode 100644 index 0000000..a84be95 --- /dev/null +++ b/migrations/100_rls_and_grants.sql @@ -0,0 +1,31 @@ +-- Grant these permissions to the database role used by the application. +-- Replace amcs with the actual role in your deployment before applying. +GRANT ALL ON TABLE public.thoughts TO amcs; +GRANT ALL ON TABLE public.projects TO amcs; +GRANT ALL ON TABLE public.thought_links TO amcs; +GRANT ALL ON TABLE public.embeddings TO amcs; + +-- Household Knowledge (011) +GRANT ALL ON TABLE public.household_items TO amcs; +GRANT ALL ON TABLE public.household_vendors TO amcs; + +-- Home Maintenance (012) +GRANT ALL ON TABLE public.maintenance_tasks TO amcs; +GRANT ALL ON TABLE public.maintenance_logs TO amcs; + +-- Family Calendar (013) +GRANT ALL ON TABLE public.family_members TO amcs; +GRANT ALL ON TABLE public.activities TO amcs; +GRANT ALL ON TABLE public.important_dates TO amcs; + +-- Meal Planning (014) +GRANT ALL ON TABLE public.recipes TO amcs; +GRANT ALL ON TABLE public.meal_plans TO amcs; +GRANT ALL ON TABLE public.shopping_lists TO amcs; + +-- Professional CRM (015) +GRANT ALL ON TABLE public.professional_contacts TO amcs; +GRANT ALL ON TABLE public.contact_interactions TO amcs; +GRANT ALL ON TABLE public.opportunities TO amcs; + +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs; \ No newline at end of file