feat(tools): add maintenance and meal planning tools with CRUD operations
- Implement maintenance tool for adding, logging, and retrieving tasks - Create meals tool for managing recipes, meal plans, and shopping lists - Introduce reparse metadata tool for updating thought metadata - Add household knowledge, home maintenance, family calendar, meal planning, and professional CRM database migrations - Grant necessary permissions for new database tables
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,4 +28,5 @@ go.work.sum
|
|||||||
# local config
|
# local config
|
||||||
configs/*.local.yaml
|
configs/*.local.yaml
|
||||||
cmd/amcs-server/__debug_*
|
cmd/amcs-server/__debug_*
|
||||||
bin/
|
bin/
|
||||||
|
OB1/
|
||||||
19
README.md
19
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 |
|
| `link_thoughts` | Create a typed relationship between thoughts |
|
||||||
| `related_thoughts` | Explicit links + semantic neighbours |
|
| `related_thoughts` | Explicit links + semantic neighbours |
|
||||||
| `backfill_embeddings` | Generate missing embeddings for stored thoughts |
|
| `backfill_embeddings` | Generate missing embeddings for stored thoughts |
|
||||||
|
| `reparse_thought_metadata` | Re-extract and normalize metadata for stored thoughts |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -93,6 +94,24 @@ Run `backfill_embeddings` after switching embedding models or importing thoughts
|
|||||||
- `limit` — max thoughts per call (default 100)
|
- `limit` — max thoughts per call (default 100)
|
||||||
- Embeddings are generated in parallel (4 workers) and upserted; one failure does not abort the run
|
- 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):
|
**Automatic backfill** (optional, config-gated):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -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),
|
Recall: tools.NewRecallTool(db, provider, cfg.Search, activeProjects),
|
||||||
Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects),
|
Summarize: tools.NewSummarizeTool(db, provider, cfg.Search, activeProjects),
|
||||||
Links: tools.NewLinksTool(db, provider, cfg.Search),
|
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)
|
mcpHandler := mcpserver.New(cfg.MCP, toolSet)
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ type ToolSet struct {
|
|||||||
Recall *tools.RecallTool
|
Recall *tools.RecallTool
|
||||||
Summarize *tools.SummarizeTool
|
Summarize *tools.SummarizeTool
|
||||||
Links *tools.LinksTool
|
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 {
|
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.",
|
Description: "Generate missing embeddings for stored thoughts using the active embedding model.",
|
||||||
}, toolSet.Backfill.Handle)
|
}, 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 mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||||
return server
|
return server
|
||||||
}, &mcp.StreamableHTTPOptions{
|
}, &mcp.StreamableHTTPOptions{
|
||||||
|
|||||||
210
internal/store/calendar.go
Normal file
210
internal/store/calendar.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into family_members (name, relationship, birth_date, notes)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
returning id, created_at
|
||||||
|
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
||||||
|
|
||||||
|
created := m
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||||
|
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
|
||||||
|
rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list family members: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var members []ext.FamilyMember
|
||||||
|
for rows.Next() {
|
||||||
|
var m ext.FamilyMember
|
||||||
|
var relationship, notes *string
|
||||||
|
if err := rows.Scan(&m.ID, &m.Name, &relationship, &m.BirthDate, ¬es, &m.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan family member: %w", err)
|
||||||
|
}
|
||||||
|
m.Relationship = strVal(relationship)
|
||||||
|
m.Notes = strVal(notes)
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
return members, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
|
||||||
|
values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
|
||||||
|
returning id, created_at
|
||||||
|
`, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
|
||||||
|
nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
|
||||||
|
nullStr(a.Location), nullStr(a.Notes))
|
||||||
|
|
||||||
|
created := a
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||||
|
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
|
||||||
|
weekEnd := weekStart.AddDate(0, 0, 7)
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||||
|
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||||
|
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||||
|
from activities a
|
||||||
|
left join family_members fm on fm.id = a.family_member_id
|
||||||
|
where (a.start_date >= $1 and a.start_date < $2)
|
||||||
|
or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
|
||||||
|
order by a.start_date, a.start_time
|
||||||
|
`, weekStart, weekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get week schedule: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanActivities(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
|
||||||
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(query); q != "" {
|
||||||
|
args = append(args, "%"+q+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
|
||||||
|
}
|
||||||
|
if t := strings.TrimSpace(activityType); t != "" {
|
||||||
|
args = append(args, t)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
|
||||||
|
}
|
||||||
|
if memberID != nil {
|
||||||
|
args = append(args, *memberID)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `
|
||||||
|
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||||
|
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||||
|
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||||
|
from activities a
|
||||||
|
left join family_members fm on fm.id = a.family_member_id
|
||||||
|
`
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
|
}
|
||||||
|
q += " order by a.start_date, a.start_time"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search activities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanActivities(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
returning id, created_at
|
||||||
|
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
||||||
|
|
||||||
|
created := d
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||||
|
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
|
||||||
|
if daysAhead <= 0 {
|
||||||
|
daysAhead = 30
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.AddDate(0, 0, daysAhead)
|
||||||
|
|
||||||
|
// For yearly recurring events, check if this year's occurrence falls in range
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select d.id, d.family_member_id, fm.name, d.title, d.date_value,
|
||||||
|
d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
|
||||||
|
from important_dates d
|
||||||
|
left join family_members fm on fm.id = d.family_member_id
|
||||||
|
where (
|
||||||
|
(d.recurring_yearly = false and d.date_value between $1 and $2)
|
||||||
|
or
|
||||||
|
(d.recurring_yearly = true and
|
||||||
|
make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
|
||||||
|
between $1 and $2)
|
||||||
|
)
|
||||||
|
order by d.date_value
|
||||||
|
`, now, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get upcoming dates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dates []ext.ImportantDate
|
||||||
|
for rows.Next() {
|
||||||
|
var d ext.ImportantDate
|
||||||
|
var memberID *uuid.UUID
|
||||||
|
var memberName, notes *string
|
||||||
|
if err := rows.Scan(&d.ID, &memberID, &memberName, &d.Title, &d.DateValue,
|
||||||
|
&d.RecurringYearly, &d.ReminderDaysBefore, ¬es, &d.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan important date: %w", err)
|
||||||
|
}
|
||||||
|
d.FamilyMemberID = memberID
|
||||||
|
d.MemberName = strVal(memberName)
|
||||||
|
d.Notes = strVal(notes)
|
||||||
|
dates = append(dates, d)
|
||||||
|
}
|
||||||
|
return dates, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanActivities(rows interface {
|
||||||
|
Next() bool
|
||||||
|
Scan(...any) error
|
||||||
|
Err() error
|
||||||
|
Close()
|
||||||
|
}) ([]ext.Activity, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
var activities []ext.Activity
|
||||||
|
for rows.Next() {
|
||||||
|
var a ext.Activity
|
||||||
|
var memberName, activityType, dayOfWeek, startTime, endTime, location, notes *string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&a.ID, &a.FamilyMemberID, &memberName, &a.Title, &activityType,
|
||||||
|
&dayOfWeek, &startTime, &endTime,
|
||||||
|
&a.StartDate, &a.EndDate, &location, ¬es, &a.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan activity: %w", err)
|
||||||
|
}
|
||||||
|
a.MemberName = strVal(memberName)
|
||||||
|
a.ActivityType = strVal(activityType)
|
||||||
|
a.DayOfWeek = strVal(dayOfWeek)
|
||||||
|
a.StartTime = strVal(startTime)
|
||||||
|
a.EndTime = strVal(endTime)
|
||||||
|
a.Location = strVal(location)
|
||||||
|
a.Notes = strVal(notes)
|
||||||
|
activities = append(activities, a)
|
||||||
|
}
|
||||||
|
return activities, rows.Err()
|
||||||
|
}
|
||||||
247
internal/store/crm.go
Normal file
247
internal/store/crm.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
|
||||||
|
if c.Tags == nil {
|
||||||
|
c.Tags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
|
||||||
|
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
||||||
|
|
||||||
|
created := c
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||||
|
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
|
||||||
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(query); q != "" {
|
||||||
|
args = append(args, "%"+q+"%")
|
||||||
|
idx := len(args)
|
||||||
|
conditions = append(conditions, fmt.Sprintf(
|
||||||
|
"(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
args = append(args, tags)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
|
}
|
||||||
|
q += " order by name"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search contacts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanContacts(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
||||||
|
from professional_contacts where id = $1
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
var c ext.ProfessionalContact
|
||||||
|
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||||
|
if err := row.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||||
|
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||||
|
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||||
|
}
|
||||||
|
c.Company = strVal(company)
|
||||||
|
c.Title = strVal(title)
|
||||||
|
c.Email = strVal(email)
|
||||||
|
c.Phone = strVal(phone)
|
||||||
|
c.LinkedInURL = strVal(linkedInURL)
|
||||||
|
c.HowWeMet = strVal(howWeMet)
|
||||||
|
c.Notes = strVal(notes)
|
||||||
|
if c.Tags == nil {
|
||||||
|
c.Tags = []string{}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
|
||||||
|
occurredAt := interaction.OccurredAt
|
||||||
|
if occurredAt.IsZero() {
|
||||||
|
occurredAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
returning id, created_at
|
||||||
|
`, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
|
||||||
|
interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
|
||||||
|
|
||||||
|
created := interaction
|
||||||
|
created.OccurredAt = occurredAt
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||||
|
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
|
||||||
|
contact, err := db.GetContact(ctx, contactID)
|
||||||
|
if err != nil {
|
||||||
|
return ext.ContactHistory{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
|
||||||
|
from contact_interactions where contact_id = $1 order by occurred_at desc
|
||||||
|
`, contactID)
|
||||||
|
if err != nil {
|
||||||
|
return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var interactions []ext.ContactInteraction
|
||||||
|
for rows.Next() {
|
||||||
|
var i ext.ContactInteraction
|
||||||
|
var followUpNotes *string
|
||||||
|
if err := rows.Scan(&i.ID, &i.ContactID, &i.InteractionType, &i.OccurredAt, &i.Summary,
|
||||||
|
&i.FollowUpNeeded, &followUpNotes, &i.CreatedAt); err != nil {
|
||||||
|
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||||
|
}
|
||||||
|
i.FollowUpNotes = strVal(followUpNotes)
|
||||||
|
interactions = append(interactions, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return ext.ContactHistory{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
oppRows, err := db.pool.Query(ctx, `
|
||||||
|
select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
|
||||||
|
from opportunities where contact_id = $1 order by created_at desc
|
||||||
|
`, contactID)
|
||||||
|
if err != nil {
|
||||||
|
return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
|
||||||
|
}
|
||||||
|
defer oppRows.Close()
|
||||||
|
|
||||||
|
var opportunities []ext.Opportunity
|
||||||
|
for oppRows.Next() {
|
||||||
|
var o ext.Opportunity
|
||||||
|
var description, notes *string
|
||||||
|
if err := oppRows.Scan(&o.ID, &o.ContactID, &o.Title, &description, &o.Stage, &o.Value,
|
||||||
|
&o.ExpectedCloseDate, ¬es, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||||
|
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||||
|
}
|
||||||
|
o.Description = strVal(description)
|
||||||
|
o.Notes = strVal(notes)
|
||||||
|
opportunities = append(opportunities, o)
|
||||||
|
}
|
||||||
|
if err := oppRows.Err(); err != nil {
|
||||||
|
return ext.ContactHistory{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ext.ContactHistory{
|
||||||
|
Contact: contact,
|
||||||
|
Interactions: interactions,
|
||||||
|
Opportunities: opportunities,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
||||||
|
|
||||||
|
created := o
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||||
|
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
|
||||||
|
if daysAhead <= 0 {
|
||||||
|
daysAhead = 7
|
||||||
|
}
|
||||||
|
cutoff := time.Now().AddDate(0, 0, daysAhead)
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
||||||
|
from professional_contacts
|
||||||
|
where follow_up_date <= $1
|
||||||
|
order by follow_up_date asc
|
||||||
|
`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get follow-ups: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanContacts(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
|
||||||
|
_, err := db.pool.Exec(ctx, `
|
||||||
|
update professional_contacts
|
||||||
|
set notes = coalesce(notes, '') || $2
|
||||||
|
where id = $1
|
||||||
|
`, contactID, thoughtContent)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("append thought to contact: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanContacts(rows interface {
|
||||||
|
Next() bool
|
||||||
|
Scan(...any) error
|
||||||
|
Err() error
|
||||||
|
Close()
|
||||||
|
}) ([]ext.ProfessionalContact, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
var contacts []ext.ProfessionalContact
|
||||||
|
for rows.Next() {
|
||||||
|
var c ext.ProfessionalContact
|
||||||
|
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||||
|
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||||
|
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan contact: %w", err)
|
||||||
|
}
|
||||||
|
c.Company = strVal(company)
|
||||||
|
c.Title = strVal(title)
|
||||||
|
c.Email = strVal(email)
|
||||||
|
c.Phone = strVal(phone)
|
||||||
|
c.LinkedInURL = strVal(linkedInURL)
|
||||||
|
c.HowWeMet = strVal(howWeMet)
|
||||||
|
c.Notes = strVal(notes)
|
||||||
|
if c.Tags == nil {
|
||||||
|
c.Tags = []string{}
|
||||||
|
}
|
||||||
|
contacts = append(contacts, c)
|
||||||
|
}
|
||||||
|
return contacts, rows.Err()
|
||||||
|
}
|
||||||
15
internal/store/helpers.go
Normal file
15
internal/store/helpers.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
func nullStr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func strVal(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
150
internal/store/household.go
Normal file
150
internal/store/household.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
|
||||||
|
details, err := json.Marshal(item.Details)
|
||||||
|
if err != nil {
|
||||||
|
return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into household_items (name, category, location, details, notes)
|
||||||
|
values ($1, $2, $3, $4::jsonb, $5)
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
|
||||||
|
|
||||||
|
created := item
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||||
|
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
|
||||||
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(query); q != "" {
|
||||||
|
args = append(args, "%"+q+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(category); c != "" {
|
||||||
|
args = append(args, c)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
|
||||||
|
}
|
||||||
|
if l := strings.TrimSpace(location); l != "" {
|
||||||
|
args = append(args, "%"+l+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
|
}
|
||||||
|
q += " order by name"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search household items: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []ext.HouseholdItem
|
||||||
|
for rows.Next() {
|
||||||
|
var item ext.HouseholdItem
|
||||||
|
var detailsBytes []byte
|
||||||
|
var category, location, notes *string
|
||||||
|
if err := rows.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan household item: %w", err)
|
||||||
|
}
|
||||||
|
item.Category = strVal(category)
|
||||||
|
item.Location = strVal(location)
|
||||||
|
item.Notes = strVal(notes)
|
||||||
|
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||||
|
item.Details = map[string]any{}
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
select id, name, category, location, details, notes, created_at, updated_at
|
||||||
|
from household_items where id = $1
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
var item ext.HouseholdItem
|
||||||
|
var detailsBytes []byte
|
||||||
|
var category, location, notes *string
|
||||||
|
if err := row.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||||
|
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
|
||||||
|
}
|
||||||
|
item.Category = strVal(category)
|
||||||
|
item.Location = strVal(location)
|
||||||
|
item.Notes = strVal(notes)
|
||||||
|
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||||
|
item.Details = map[string]any{}
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
returning id, created_at
|
||||||
|
`, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
|
||||||
|
nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
|
||||||
|
|
||||||
|
created := v
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||||
|
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
|
||||||
|
args := []any{}
|
||||||
|
q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
|
||||||
|
if st := strings.TrimSpace(serviceType); st != "" {
|
||||||
|
args = append(args, st)
|
||||||
|
q += " where service_type = $1"
|
||||||
|
}
|
||||||
|
q += " order by name"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list vendors: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var vendors []ext.HouseholdVendor
|
||||||
|
for rows.Next() {
|
||||||
|
var v ext.HouseholdVendor
|
||||||
|
var serviceType, phone, email, website, notes *string
|
||||||
|
var lastUsed *time.Time
|
||||||
|
if err := rows.Scan(&v.ID, &v.Name, &serviceType, &phone, &email, &website, ¬es, &v.Rating, &lastUsed, &v.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan vendor: %w", err)
|
||||||
|
}
|
||||||
|
v.ServiceType = strVal(serviceType)
|
||||||
|
v.Phone = strVal(phone)
|
||||||
|
v.Email = strVal(email)
|
||||||
|
v.Website = strVal(website)
|
||||||
|
v.Notes = strVal(notes)
|
||||||
|
v.LastUsed = lastUsed
|
||||||
|
vendors = append(vendors, v)
|
||||||
|
}
|
||||||
|
return vendors, rows.Err()
|
||||||
|
}
|
||||||
142
internal/store/maintenance.go
Normal file
142
internal/store/maintenance.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
|
||||||
|
|
||||||
|
created := t
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||||
|
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
|
||||||
|
completedAt := log.CompletedAt
|
||||||
|
if completedAt.IsZero() {
|
||||||
|
completedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
returning id
|
||||||
|
`, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
|
||||||
|
|
||||||
|
created := log
|
||||||
|
created.CompletedAt = completedAt
|
||||||
|
if err := row.Scan(&created.ID); err != nil {
|
||||||
|
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
|
||||||
|
if daysAhead <= 0 {
|
||||||
|
daysAhead = 30
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
|
||||||
|
from maintenance_tasks
|
||||||
|
where next_due <= $1 or next_due is null
|
||||||
|
order by next_due asc nulls last, priority desc
|
||||||
|
`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get upcoming maintenance: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanMaintenanceTasks(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
|
||||||
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(query); q != "" {
|
||||||
|
args = append(args, "%"+q+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(category); c != "" {
|
||||||
|
args = append(args, c)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
|
||||||
|
}
|
||||||
|
if start != nil {
|
||||||
|
args = append(args, *start)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
|
||||||
|
}
|
||||||
|
if end != nil {
|
||||||
|
args = append(args, *end)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `
|
||||||
|
select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
|
||||||
|
mt.name, mt.category
|
||||||
|
from maintenance_logs ml
|
||||||
|
join maintenance_tasks mt on mt.id = ml.task_id
|
||||||
|
`
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
|
}
|
||||||
|
q += " order by ml.completed_at desc"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search maintenance history: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []ext.MaintenanceLogWithTask
|
||||||
|
for rows.Next() {
|
||||||
|
var l ext.MaintenanceLogWithTask
|
||||||
|
var performedBy, notes, nextAction, taskCategory *string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.ID, &l.TaskID, &l.CompletedAt, &performedBy, &l.Cost, ¬es, &nextAction,
|
||||||
|
&l.TaskName, &taskCategory,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan maintenance log: %w", err)
|
||||||
|
}
|
||||||
|
l.PerformedBy = strVal(performedBy)
|
||||||
|
l.Notes = strVal(notes)
|
||||||
|
l.NextAction = strVal(nextAction)
|
||||||
|
l.TaskCategory = strVal(taskCategory)
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanMaintenanceTasks(rows interface {
|
||||||
|
Next() bool
|
||||||
|
Scan(...any) error
|
||||||
|
Err() error
|
||||||
|
Close()
|
||||||
|
}) ([]ext.MaintenanceTask, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
var tasks []ext.MaintenanceTask
|
||||||
|
for rows.Next() {
|
||||||
|
var t ext.MaintenanceTask
|
||||||
|
var category, notes *string
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &category, &t.FrequencyDays, &t.LastCompleted, &t.NextDue, &t.Priority, ¬es, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan maintenance task: %w", err)
|
||||||
|
}
|
||||||
|
t.Category = strVal(category)
|
||||||
|
t.Notes = strVal(notes)
|
||||||
|
tasks = append(tasks, t)
|
||||||
|
}
|
||||||
|
return tasks, rows.Err()
|
||||||
|
}
|
||||||
289
internal/store/meals.go
Normal file
289
internal/store/meals.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
|
||||||
|
ingredients, err := json.Marshal(r.Ingredients)
|
||||||
|
if err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||||
|
}
|
||||||
|
instructions, err := json.Marshal(r.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||||
|
}
|
||||||
|
if r.Tags == nil {
|
||||||
|
r.Tags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||||
|
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||||
|
|
||||||
|
created := r
|
||||||
|
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
|
||||||
|
args := []any{}
|
||||||
|
conditions := []string{}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(query); q != "" {
|
||||||
|
args = append(args, "%"+q+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(cuisine); c != "" {
|
||||||
|
args = append(args, c)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
args = append(args, tags)
|
||||||
|
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||||
|
}
|
||||||
|
if ing := strings.TrimSpace(ingredient); ing != "" {
|
||||||
|
args = append(args, "%"+ing+"%")
|
||||||
|
conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at from recipes`
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
q += " where " + strings.Join(conditions, " and ")
|
||||||
|
}
|
||||||
|
q += " order by name"
|
||||||
|
|
||||||
|
rows, err := db.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search recipes: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var recipes []ext.Recipe
|
||||||
|
for rows.Next() {
|
||||||
|
r, err := scanRecipeRow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recipes = append(recipes, r)
|
||||||
|
}
|
||||||
|
return recipes, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at
|
||||||
|
from recipes where id = $1
|
||||||
|
`, id)
|
||||||
|
|
||||||
|
var r ext.Recipe
|
||||||
|
var cuisine, notes *string
|
||||||
|
var ingredientsBytes, instructionsBytes []byte
|
||||||
|
if err := row.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||||
|
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
|
||||||
|
}
|
||||||
|
r.Cuisine = strVal(cuisine)
|
||||||
|
r.Notes = strVal(notes)
|
||||||
|
if r.Tags == nil {
|
||||||
|
r.Tags = []string{}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||||
|
r.Ingredients = []ext.Ingredient{}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||||
|
r.Instructions = []string{}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
|
||||||
|
ingredients, err := json.Marshal(r.Ingredients)
|
||||||
|
if err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||||
|
}
|
||||||
|
instructions, err := json.Marshal(r.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||||
|
}
|
||||||
|
if r.Tags == nil {
|
||||||
|
r.Tags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.pool.Exec(ctx, `
|
||||||
|
update recipes set
|
||||||
|
name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
|
||||||
|
servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
|
||||||
|
tags = $9, rating = $10, notes = $11
|
||||||
|
where id = $1
|
||||||
|
`, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||||
|
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||||
|
if err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
|
||||||
|
}
|
||||||
|
return db.GetRecipe(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
|
||||||
|
if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
|
||||||
|
return nil, fmt.Errorf("clear meal plan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []ext.MealPlanEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
returning id, created_at
|
||||||
|
`, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
|
||||||
|
|
||||||
|
entry := ext.MealPlanEntry{
|
||||||
|
WeekStart: weekStart,
|
||||||
|
DayOfWeek: e.DayOfWeek,
|
||||||
|
MealType: e.MealType,
|
||||||
|
RecipeID: e.RecipeID,
|
||||||
|
CustomMeal: e.CustomMeal,
|
||||||
|
Servings: e.Servings,
|
||||||
|
Notes: e.Notes,
|
||||||
|
}
|
||||||
|
if err := row.Scan(&entry.ID, &entry.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("insert meal plan entry: %w", err)
|
||||||
|
}
|
||||||
|
results = append(results, entry)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
|
||||||
|
rows, err := db.pool.Query(ctx, `
|
||||||
|
select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
|
||||||
|
from meal_plans mp
|
||||||
|
left join recipes r on r.id = mp.recipe_id
|
||||||
|
where mp.week_start = $1
|
||||||
|
order by
|
||||||
|
case mp.day_of_week
|
||||||
|
when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
|
||||||
|
when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
|
||||||
|
when 'sunday' then 7 else 8
|
||||||
|
end,
|
||||||
|
case mp.meal_type
|
||||||
|
when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
|
||||||
|
when 'snack' then 4 else 5
|
||||||
|
end
|
||||||
|
`, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get meal plan: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []ext.MealPlanEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e ext.MealPlanEntry
|
||||||
|
var recipeName, customMeal, notes *string
|
||||||
|
if err := rows.Scan(&e.ID, &e.WeekStart, &e.DayOfWeek, &e.MealType, &e.RecipeID, &recipeName, &customMeal, &e.Servings, ¬es, &e.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan meal plan entry: %w", err)
|
||||||
|
}
|
||||||
|
e.RecipeName = strVal(recipeName)
|
||||||
|
e.CustomMeal = strVal(customMeal)
|
||||||
|
e.Notes = strVal(notes)
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
|
||||||
|
entries, err := db.GetMealPlan(ctx, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return ext.ShoppingList{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
recipeIDs := map[uuid.UUID]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.RecipeID != nil {
|
||||||
|
recipeIDs[*e.RecipeID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated := map[string]*ext.ShoppingItem{}
|
||||||
|
for id := range recipeIDs {
|
||||||
|
recipe, err := db.GetRecipe(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ing := range recipe.Ingredients {
|
||||||
|
key := strings.ToLower(ing.Name)
|
||||||
|
if existing, ok := aggregated[key]; ok {
|
||||||
|
if ing.Quantity != "" {
|
||||||
|
existing.Quantity += "+" + ing.Quantity
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recipeIDCopy := id
|
||||||
|
aggregated[key] = &ext.ShoppingItem{
|
||||||
|
Name: ing.Name,
|
||||||
|
Quantity: ing.Quantity,
|
||||||
|
Unit: ing.Unit,
|
||||||
|
Purchased: false,
|
||||||
|
RecipeID: &recipeIDCopy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]ext.ShoppingItem, 0, len(aggregated))
|
||||||
|
for _, item := range aggregated {
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsJSON, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
insert into shopping_lists (week_start, items)
|
||||||
|
values ($1, $2::jsonb)
|
||||||
|
on conflict (week_start) do update set items = excluded.items, updated_at = now()
|
||||||
|
returning id, created_at, updated_at
|
||||||
|
`, weekStart, itemsJSON)
|
||||||
|
|
||||||
|
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
|
||||||
|
if err := row.Scan(&list.ID, &list.CreatedAt, &list.UpdatedAt); err != nil {
|
||||||
|
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
|
||||||
|
var r ext.Recipe
|
||||||
|
var cuisine, notes *string
|
||||||
|
var ingredientsBytes, instructionsBytes []byte
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||||
|
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
|
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
|
||||||
|
}
|
||||||
|
r.Cuisine = strVal(cuisine)
|
||||||
|
r.Notes = strVal(notes)
|
||||||
|
if r.Tags == nil {
|
||||||
|
r.Tags = []string{}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||||
|
r.Ingredients = []ext.Ingredient{}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||||
|
r.Instructions = []string{}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
@@ -428,6 +428,57 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
|
|||||||
return thoughts, nil
|
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 {
|
func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error {
|
||||||
_, err := db.pool.Exec(ctx, `
|
_, err := db.pool.Exec(ctx, `
|
||||||
insert into embeddings (thought_id, model, dim, embedding)
|
insert into embeddings (thought_id, model, dim, embedding)
|
||||||
|
|||||||
212
internal/tools/calendar.go
Normal file
212
internal/tools/calendar.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CalendarTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCalendarTool(db *store.DB) *CalendarTool {
|
||||||
|
return &CalendarTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_family_member
|
||||||
|
|
||||||
|
type AddFamilyMemberInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"person's name"`
|
||||||
|
Relationship string `json:"relationship,omitempty" jsonschema:"e.g. self, spouse, child, parent"`
|
||||||
|
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddFamilyMemberOutput struct {
|
||||||
|
Member ext.FamilyMember `json:"member"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) AddMember(ctx context.Context, _ *mcp.CallToolRequest, in AddFamilyMemberInput) (*mcp.CallToolResult, AddFamilyMemberOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddFamilyMemberOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
member, err := t.store.AddFamilyMember(ctx, ext.FamilyMember{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Relationship: strings.TrimSpace(in.Relationship),
|
||||||
|
BirthDate: in.BirthDate,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddFamilyMemberOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddFamilyMemberOutput{Member: member}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// list_family_members
|
||||||
|
|
||||||
|
type ListFamilyMembersInput struct{}
|
||||||
|
|
||||||
|
type ListFamilyMembersOutput struct {
|
||||||
|
Members []ext.FamilyMember `json:"members"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) ListMembers(ctx context.Context, _ *mcp.CallToolRequest, _ ListFamilyMembersInput) (*mcp.CallToolResult, ListFamilyMembersOutput, error) {
|
||||||
|
members, err := t.store.ListFamilyMembers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ListFamilyMembersOutput{}, err
|
||||||
|
}
|
||||||
|
if members == nil {
|
||||||
|
members = []ext.FamilyMember{}
|
||||||
|
}
|
||||||
|
return nil, ListFamilyMembersOutput{Members: members}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_activity
|
||||||
|
|
||||||
|
type AddActivityInput struct {
|
||||||
|
Title string `json:"title" jsonschema:"activity title"`
|
||||||
|
ActivityType string `json:"activity_type,omitempty" jsonschema:"e.g. sports, medical, school, social"`
|
||||||
|
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"leave empty for whole-family activities"`
|
||||||
|
DayOfWeek string `json:"day_of_week,omitempty" jsonschema:"for recurring: monday, tuesday, etc."`
|
||||||
|
StartTime string `json:"start_time,omitempty" jsonschema:"HH:MM format"`
|
||||||
|
EndTime string `json:"end_time,omitempty" jsonschema:"HH:MM format"`
|
||||||
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
|
EndDate *time.Time `json:"end_date,omitempty" jsonschema:"for recurring activities, when they end"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddActivityOutput struct {
|
||||||
|
Activity ext.Activity `json:"activity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) AddActivity(ctx context.Context, _ *mcp.CallToolRequest, in AddActivityInput) (*mcp.CallToolResult, AddActivityOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Title) == "" {
|
||||||
|
return nil, AddActivityOutput{}, errInvalidInput("title is required")
|
||||||
|
}
|
||||||
|
activity, err := t.store.AddActivity(ctx, ext.Activity{
|
||||||
|
FamilyMemberID: in.FamilyMemberID,
|
||||||
|
Title: strings.TrimSpace(in.Title),
|
||||||
|
ActivityType: strings.TrimSpace(in.ActivityType),
|
||||||
|
DayOfWeek: strings.ToLower(strings.TrimSpace(in.DayOfWeek)),
|
||||||
|
StartTime: strings.TrimSpace(in.StartTime),
|
||||||
|
EndTime: strings.TrimSpace(in.EndTime),
|
||||||
|
StartDate: in.StartDate,
|
||||||
|
EndDate: in.EndDate,
|
||||||
|
Location: strings.TrimSpace(in.Location),
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddActivityOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddActivityOutput{Activity: activity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_week_schedule
|
||||||
|
|
||||||
|
type GetWeekScheduleInput struct {
|
||||||
|
WeekStart time.Time `json:"week_start" jsonschema:"start of the week (Monday) to retrieve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetWeekScheduleOutput struct {
|
||||||
|
Activities []ext.Activity `json:"activities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) GetWeekSchedule(ctx context.Context, _ *mcp.CallToolRequest, in GetWeekScheduleInput) (*mcp.CallToolResult, GetWeekScheduleOutput, error) {
|
||||||
|
activities, err := t.store.GetWeekSchedule(ctx, in.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetWeekScheduleOutput{}, err
|
||||||
|
}
|
||||||
|
if activities == nil {
|
||||||
|
activities = []ext.Activity{}
|
||||||
|
}
|
||||||
|
return nil, GetWeekScheduleOutput{Activities: activities}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search_activities
|
||||||
|
|
||||||
|
type SearchActivitiesInput struct {
|
||||||
|
Query string `json:"query,omitempty" jsonschema:"search text matching title or notes"`
|
||||||
|
ActivityType string `json:"activity_type,omitempty" jsonschema:"filter by type"`
|
||||||
|
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty" jsonschema:"filter by family member"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchActivitiesOutput struct {
|
||||||
|
Activities []ext.Activity `json:"activities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) SearchActivities(ctx context.Context, _ *mcp.CallToolRequest, in SearchActivitiesInput) (*mcp.CallToolResult, SearchActivitiesOutput, error) {
|
||||||
|
activities, err := t.store.SearchActivities(ctx, in.Query, in.ActivityType, in.FamilyMemberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, SearchActivitiesOutput{}, err
|
||||||
|
}
|
||||||
|
if activities == nil {
|
||||||
|
activities = []ext.Activity{}
|
||||||
|
}
|
||||||
|
return nil, SearchActivitiesOutput{Activities: activities}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_important_date
|
||||||
|
|
||||||
|
type AddImportantDateInput struct {
|
||||||
|
Title string `json:"title" jsonschema:"description of the date"`
|
||||||
|
DateValue time.Time `json:"date_value" jsonschema:"the date"`
|
||||||
|
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||||
|
RecurringYearly bool `json:"recurring_yearly,omitempty" jsonschema:"if true, reminds every year"`
|
||||||
|
ReminderDaysBefore int `json:"reminder_days_before,omitempty" jsonschema:"how many days before to remind (default: 7)"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddImportantDateOutput struct {
|
||||||
|
Date ext.ImportantDate `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) AddImportantDate(ctx context.Context, _ *mcp.CallToolRequest, in AddImportantDateInput) (*mcp.CallToolResult, AddImportantDateOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Title) == "" {
|
||||||
|
return nil, AddImportantDateOutput{}, errInvalidInput("title is required")
|
||||||
|
}
|
||||||
|
reminder := in.ReminderDaysBefore
|
||||||
|
if reminder <= 0 {
|
||||||
|
reminder = 7
|
||||||
|
}
|
||||||
|
d, err := t.store.AddImportantDate(ctx, ext.ImportantDate{
|
||||||
|
FamilyMemberID: in.FamilyMemberID,
|
||||||
|
Title: strings.TrimSpace(in.Title),
|
||||||
|
DateValue: in.DateValue,
|
||||||
|
RecurringYearly: in.RecurringYearly,
|
||||||
|
ReminderDaysBefore: reminder,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddImportantDateOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddImportantDateOutput{Date: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_upcoming_dates
|
||||||
|
|
||||||
|
type GetUpcomingDatesInput struct {
|
||||||
|
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUpcomingDatesOutput struct {
|
||||||
|
Dates []ext.ImportantDate `json:"dates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CalendarTool) GetUpcomingDates(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingDatesInput) (*mcp.CallToolResult, GetUpcomingDatesOutput, error) {
|
||||||
|
dates, err := t.store.GetUpcomingDates(ctx, in.DaysAhead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetUpcomingDatesOutput{}, err
|
||||||
|
}
|
||||||
|
if dates == nil {
|
||||||
|
dates = []ext.ImportantDate{}
|
||||||
|
}
|
||||||
|
return nil, GetUpcomingDatesOutput{Dates: dates}, nil
|
||||||
|
}
|
||||||
232
internal/tools/crm.go
Normal file
232
internal/tools/crm.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CRMTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCRMTool(db *store.DB) *CRMTool {
|
||||||
|
return &CRMTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_professional_contact
|
||||||
|
|
||||||
|
type AddContactInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"contact's full name"`
|
||||||
|
Company string `json:"company,omitempty"`
|
||||||
|
Title string `json:"title,omitempty" jsonschema:"job title"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
LinkedInURL string `json:"linkedin_url,omitempty"`
|
||||||
|
HowWeMet string `json:"how_we_met,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddContactOutput struct {
|
||||||
|
Contact ext.ProfessionalContact `json:"contact"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) AddContact(ctx context.Context, _ *mcp.CallToolRequest, in AddContactInput) (*mcp.CallToolResult, AddContactOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddContactOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
if in.Tags == nil {
|
||||||
|
in.Tags = []string{}
|
||||||
|
}
|
||||||
|
contact, err := t.store.AddProfessionalContact(ctx, ext.ProfessionalContact{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Company: strings.TrimSpace(in.Company),
|
||||||
|
Title: strings.TrimSpace(in.Title),
|
||||||
|
Email: strings.TrimSpace(in.Email),
|
||||||
|
Phone: strings.TrimSpace(in.Phone),
|
||||||
|
LinkedInURL: strings.TrimSpace(in.LinkedInURL),
|
||||||
|
HowWeMet: strings.TrimSpace(in.HowWeMet),
|
||||||
|
Tags: in.Tags,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
FollowUpDate: in.FollowUpDate,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddContactOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddContactOutput{Contact: contact}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search_contacts
|
||||||
|
|
||||||
|
type SearchContactsInput struct {
|
||||||
|
Query string `json:"query,omitempty" jsonschema:"search text matching name, company, title, or notes"`
|
||||||
|
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchContactsOutput struct {
|
||||||
|
Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) SearchContacts(ctx context.Context, _ *mcp.CallToolRequest, in SearchContactsInput) (*mcp.CallToolResult, SearchContactsOutput, error) {
|
||||||
|
contacts, err := t.store.SearchContacts(ctx, in.Query, in.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, SearchContactsOutput{}, err
|
||||||
|
}
|
||||||
|
if contacts == nil {
|
||||||
|
contacts = []ext.ProfessionalContact{}
|
||||||
|
}
|
||||||
|
return nil, SearchContactsOutput{Contacts: contacts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// log_interaction
|
||||||
|
|
||||||
|
type LogInteractionInput struct {
|
||||||
|
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||||
|
InteractionType string `json:"interaction_type" jsonschema:"one of: meeting, email, call, coffee, event, linkedin, other"`
|
||||||
|
OccurredAt *time.Time `json:"occurred_at,omitempty" jsonschema:"when it happened (defaults to now)"`
|
||||||
|
Summary string `json:"summary" jsonschema:"summary of the interaction"`
|
||||||
|
FollowUpNeeded bool `json:"follow_up_needed,omitempty"`
|
||||||
|
FollowUpNotes string `json:"follow_up_notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogInteractionOutput struct {
|
||||||
|
Interaction ext.ContactInteraction `json:"interaction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) LogInteraction(ctx context.Context, _ *mcp.CallToolRequest, in LogInteractionInput) (*mcp.CallToolResult, LogInteractionOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Summary) == "" {
|
||||||
|
return nil, LogInteractionOutput{}, errInvalidInput("summary is required")
|
||||||
|
}
|
||||||
|
occurredAt := time.Now()
|
||||||
|
if in.OccurredAt != nil {
|
||||||
|
occurredAt = *in.OccurredAt
|
||||||
|
}
|
||||||
|
interaction, err := t.store.LogInteraction(ctx, ext.ContactInteraction{
|
||||||
|
ContactID: in.ContactID,
|
||||||
|
InteractionType: in.InteractionType,
|
||||||
|
OccurredAt: occurredAt,
|
||||||
|
Summary: strings.TrimSpace(in.Summary),
|
||||||
|
FollowUpNeeded: in.FollowUpNeeded,
|
||||||
|
FollowUpNotes: strings.TrimSpace(in.FollowUpNotes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, LogInteractionOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, LogInteractionOutput{Interaction: interaction}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_contact_history
|
||||||
|
|
||||||
|
type GetContactHistoryInput struct {
|
||||||
|
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetContactHistoryOutput struct {
|
||||||
|
History ext.ContactHistory `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) GetHistory(ctx context.Context, _ *mcp.CallToolRequest, in GetContactHistoryInput) (*mcp.CallToolResult, GetContactHistoryOutput, error) {
|
||||||
|
history, err := t.store.GetContactHistory(ctx, in.ContactID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetContactHistoryOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, GetContactHistoryOutput{History: history}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// create_opportunity
|
||||||
|
|
||||||
|
type CreateOpportunityInput struct {
|
||||||
|
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||||
|
Title string `json:"title" jsonschema:"opportunity title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty" jsonschema:"one of: identified, in_conversation, proposal, negotiation, won, lost (default: identified)"`
|
||||||
|
Value *float64 `json:"value,omitempty" jsonschema:"monetary value"`
|
||||||
|
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateOpportunityOutput struct {
|
||||||
|
Opportunity ext.Opportunity `json:"opportunity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) CreateOpportunity(ctx context.Context, _ *mcp.CallToolRequest, in CreateOpportunityInput) (*mcp.CallToolResult, CreateOpportunityOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Title) == "" {
|
||||||
|
return nil, CreateOpportunityOutput{}, errInvalidInput("title is required")
|
||||||
|
}
|
||||||
|
stage := strings.TrimSpace(in.Stage)
|
||||||
|
if stage == "" {
|
||||||
|
stage = "identified"
|
||||||
|
}
|
||||||
|
opp, err := t.store.CreateOpportunity(ctx, ext.Opportunity{
|
||||||
|
ContactID: in.ContactID,
|
||||||
|
Title: strings.TrimSpace(in.Title),
|
||||||
|
Description: strings.TrimSpace(in.Description),
|
||||||
|
Stage: stage,
|
||||||
|
Value: in.Value,
|
||||||
|
ExpectedCloseDate: in.ExpectedCloseDate,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, CreateOpportunityOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, CreateOpportunityOutput{Opportunity: opp}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_follow_ups_due
|
||||||
|
|
||||||
|
type GetFollowUpsDueInput struct {
|
||||||
|
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"look ahead window in days (default: 7)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetFollowUpsDueOutput struct {
|
||||||
|
Contacts []ext.ProfessionalContact `json:"contacts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) GetFollowUpsDue(ctx context.Context, _ *mcp.CallToolRequest, in GetFollowUpsDueInput) (*mcp.CallToolResult, GetFollowUpsDueOutput, error) {
|
||||||
|
contacts, err := t.store.GetFollowUpsDue(ctx, in.DaysAhead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetFollowUpsDueOutput{}, err
|
||||||
|
}
|
||||||
|
if contacts == nil {
|
||||||
|
contacts = []ext.ProfessionalContact{}
|
||||||
|
}
|
||||||
|
return nil, GetFollowUpsDueOutput{Contacts: contacts}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// link_thought_to_contact
|
||||||
|
|
||||||
|
type LinkThoughtToContactInput struct {
|
||||||
|
ContactID uuid.UUID `json:"contact_id" jsonschema:"id of the contact"`
|
||||||
|
ThoughtID uuid.UUID `json:"thought_id" jsonschema:"id of the thought to link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkThoughtToContactOutput struct {
|
||||||
|
Contact ext.ProfessionalContact `json:"contact"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *CRMTool) LinkThought(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtToContactInput) (*mcp.CallToolResult, LinkThoughtToContactOutput, error) {
|
||||||
|
thought, err := t.store.GetThought(ctx, in.ThoughtID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, LinkThoughtToContactOutput{}, fmt.Errorf("thought not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appendText := fmt.Sprintf("\n\n[Linked thought %s]: %s", thought.ID, thought.Content)
|
||||||
|
if err := t.store.AppendThoughtToContactNotes(ctx, in.ContactID, appendText); err != nil {
|
||||||
|
return nil, LinkThoughtToContactOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
contact, err := t.store.GetContact(ctx, in.ContactID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, LinkThoughtToContactOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, LinkThoughtToContactOutput{Contact: contact}, nil
|
||||||
|
}
|
||||||
151
internal/tools/household.go
Normal file
151
internal/tools/household.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HouseholdTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHouseholdTool(db *store.DB) *HouseholdTool {
|
||||||
|
return &HouseholdTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_household_item
|
||||||
|
|
||||||
|
type AddHouseholdItemInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"name of the item"`
|
||||||
|
Category string `json:"category,omitempty" jsonschema:"category (e.g. paint, appliance, measurement, document)"`
|
||||||
|
Location string `json:"location,omitempty" jsonschema:"where in the home this item is"`
|
||||||
|
Details map[string]any `json:"details,omitempty" jsonschema:"flexible metadata (model numbers, colors, specs, etc.)"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddHouseholdItemOutput struct {
|
||||||
|
Item ext.HouseholdItem `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HouseholdTool) AddItem(ctx context.Context, _ *mcp.CallToolRequest, in AddHouseholdItemInput) (*mcp.CallToolResult, AddHouseholdItemOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddHouseholdItemOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
if in.Details == nil {
|
||||||
|
in.Details = map[string]any{}
|
||||||
|
}
|
||||||
|
item, err := t.store.AddHouseholdItem(ctx, ext.HouseholdItem{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Category: strings.TrimSpace(in.Category),
|
||||||
|
Location: strings.TrimSpace(in.Location),
|
||||||
|
Details: in.Details,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddHouseholdItemOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddHouseholdItemOutput{Item: item}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search_household_items
|
||||||
|
|
||||||
|
type SearchHouseholdItemsInput struct {
|
||||||
|
Query string `json:"query,omitempty" jsonschema:"search text matching name or notes"`
|
||||||
|
Category string `json:"category,omitempty" jsonschema:"filter by category"`
|
||||||
|
Location string `json:"location,omitempty" jsonschema:"filter by location"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchHouseholdItemsOutput struct {
|
||||||
|
Items []ext.HouseholdItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HouseholdTool) SearchItems(ctx context.Context, _ *mcp.CallToolRequest, in SearchHouseholdItemsInput) (*mcp.CallToolResult, SearchHouseholdItemsOutput, error) {
|
||||||
|
items, err := t.store.SearchHouseholdItems(ctx, in.Query, in.Category, in.Location)
|
||||||
|
if err != nil {
|
||||||
|
return nil, SearchHouseholdItemsOutput{}, err
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []ext.HouseholdItem{}
|
||||||
|
}
|
||||||
|
return nil, SearchHouseholdItemsOutput{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_household_item
|
||||||
|
|
||||||
|
type GetHouseholdItemInput struct {
|
||||||
|
ID uuid.UUID `json:"id" jsonschema:"item id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetHouseholdItemOutput struct {
|
||||||
|
Item ext.HouseholdItem `json:"item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HouseholdTool) GetItem(ctx context.Context, _ *mcp.CallToolRequest, in GetHouseholdItemInput) (*mcp.CallToolResult, GetHouseholdItemOutput, error) {
|
||||||
|
item, err := t.store.GetHouseholdItem(ctx, in.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetHouseholdItemOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, GetHouseholdItemOutput{Item: item}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_vendor
|
||||||
|
|
||||||
|
type AddVendorInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"vendor name"`
|
||||||
|
ServiceType string `json:"service_type,omitempty" jsonschema:"type of service (e.g. plumber, electrician, landscaper)"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Website string `json:"website,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddVendorOutput struct {
|
||||||
|
Vendor ext.HouseholdVendor `json:"vendor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HouseholdTool) AddVendor(ctx context.Context, _ *mcp.CallToolRequest, in AddVendorInput) (*mcp.CallToolResult, AddVendorOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddVendorOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
vendor, err := t.store.AddVendor(ctx, ext.HouseholdVendor{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
ServiceType: strings.TrimSpace(in.ServiceType),
|
||||||
|
Phone: strings.TrimSpace(in.Phone),
|
||||||
|
Email: strings.TrimSpace(in.Email),
|
||||||
|
Website: strings.TrimSpace(in.Website),
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
Rating: in.Rating,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddVendorOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddVendorOutput{Vendor: vendor}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// list_vendors
|
||||||
|
|
||||||
|
type ListVendorsInput struct {
|
||||||
|
ServiceType string `json:"service_type,omitempty" jsonschema:"filter by service type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListVendorsOutput struct {
|
||||||
|
Vendors []ext.HouseholdVendor `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *HouseholdTool) ListVendors(ctx context.Context, _ *mcp.CallToolRequest, in ListVendorsInput) (*mcp.CallToolResult, ListVendorsOutput, error) {
|
||||||
|
vendors, err := t.store.ListVendors(ctx, in.ServiceType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ListVendorsOutput{}, err
|
||||||
|
}
|
||||||
|
if vendors == nil {
|
||||||
|
vendors = []ext.HouseholdVendor{}
|
||||||
|
}
|
||||||
|
return nil, ListVendorsOutput{Vendors: vendors}, nil
|
||||||
|
}
|
||||||
137
internal/tools/maintenance.go
Normal file
137
internal/tools/maintenance.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MaintenanceTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMaintenanceTool(db *store.DB) *MaintenanceTool {
|
||||||
|
return &MaintenanceTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_maintenance_task
|
||||||
|
|
||||||
|
type AddMaintenanceTaskInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"task name"`
|
||||||
|
Category string `json:"category,omitempty" jsonschema:"e.g. hvac, plumbing, exterior, appliance, landscaping"`
|
||||||
|
FrequencyDays *int `json:"frequency_days,omitempty" jsonschema:"recurrence interval in days; omit for one-time tasks"`
|
||||||
|
NextDue *time.Time `json:"next_due,omitempty" jsonschema:"when the task is next due"`
|
||||||
|
Priority string `json:"priority,omitempty" jsonschema:"low, medium, high, or urgent (default: medium)"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddMaintenanceTaskOutput struct {
|
||||||
|
Task ext.MaintenanceTask `json:"task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MaintenanceTool) AddTask(ctx context.Context, _ *mcp.CallToolRequest, in AddMaintenanceTaskInput) (*mcp.CallToolResult, AddMaintenanceTaskOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddMaintenanceTaskOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
priority := strings.TrimSpace(in.Priority)
|
||||||
|
if priority == "" {
|
||||||
|
priority = "medium"
|
||||||
|
}
|
||||||
|
task, err := t.store.AddMaintenanceTask(ctx, ext.MaintenanceTask{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Category: strings.TrimSpace(in.Category),
|
||||||
|
FrequencyDays: in.FrequencyDays,
|
||||||
|
NextDue: in.NextDue,
|
||||||
|
Priority: priority,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddMaintenanceTaskOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddMaintenanceTaskOutput{Task: task}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// log_maintenance
|
||||||
|
|
||||||
|
type LogMaintenanceInput struct {
|
||||||
|
TaskID uuid.UUID `json:"task_id" jsonschema:"id of the maintenance task"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty" jsonschema:"when the work was done (defaults to now)"`
|
||||||
|
PerformedBy string `json:"performed_by,omitempty" jsonschema:"who did the work (self, vendor name, etc.)"`
|
||||||
|
Cost *float64 `json:"cost,omitempty" jsonschema:"cost of the work"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
NextAction string `json:"next_action,omitempty" jsonschema:"recommended follow-up"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogMaintenanceOutput struct {
|
||||||
|
Log ext.MaintenanceLog `json:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MaintenanceTool) LogWork(ctx context.Context, _ *mcp.CallToolRequest, in LogMaintenanceInput) (*mcp.CallToolResult, LogMaintenanceOutput, error) {
|
||||||
|
completedAt := time.Now()
|
||||||
|
if in.CompletedAt != nil {
|
||||||
|
completedAt = *in.CompletedAt
|
||||||
|
}
|
||||||
|
log, err := t.store.LogMaintenance(ctx, ext.MaintenanceLog{
|
||||||
|
TaskID: in.TaskID,
|
||||||
|
CompletedAt: completedAt,
|
||||||
|
PerformedBy: strings.TrimSpace(in.PerformedBy),
|
||||||
|
Cost: in.Cost,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
NextAction: strings.TrimSpace(in.NextAction),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, LogMaintenanceOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, LogMaintenanceOutput{Log: log}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_upcoming_maintenance
|
||||||
|
|
||||||
|
type GetUpcomingMaintenanceInput struct {
|
||||||
|
DaysAhead int `json:"days_ahead,omitempty" jsonschema:"how many days to look ahead (default: 30)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetUpcomingMaintenanceOutput struct {
|
||||||
|
Tasks []ext.MaintenanceTask `json:"tasks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MaintenanceTool) GetUpcoming(ctx context.Context, _ *mcp.CallToolRequest, in GetUpcomingMaintenanceInput) (*mcp.CallToolResult, GetUpcomingMaintenanceOutput, error) {
|
||||||
|
tasks, err := t.store.GetUpcomingMaintenance(ctx, in.DaysAhead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetUpcomingMaintenanceOutput{}, err
|
||||||
|
}
|
||||||
|
if tasks == nil {
|
||||||
|
tasks = []ext.MaintenanceTask{}
|
||||||
|
}
|
||||||
|
return nil, GetUpcomingMaintenanceOutput{Tasks: tasks}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search_maintenance_history
|
||||||
|
|
||||||
|
type SearchMaintenanceHistoryInput struct {
|
||||||
|
Query string `json:"query,omitempty" jsonschema:"search text matching task name or notes"`
|
||||||
|
Category string `json:"category,omitempty" jsonschema:"filter by task category"`
|
||||||
|
Start *time.Time `json:"start,omitempty" jsonschema:"filter logs completed on or after this date"`
|
||||||
|
End *time.Time `json:"end,omitempty" jsonschema:"filter logs completed on or before this date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchMaintenanceHistoryOutput struct {
|
||||||
|
Logs []ext.MaintenanceLogWithTask `json:"logs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MaintenanceTool) SearchHistory(ctx context.Context, _ *mcp.CallToolRequest, in SearchMaintenanceHistoryInput) (*mcp.CallToolResult, SearchMaintenanceHistoryOutput, error) {
|
||||||
|
logs, err := t.store.SearchMaintenanceHistory(ctx, in.Query, in.Category, in.Start, in.End)
|
||||||
|
if err != nil {
|
||||||
|
return nil, SearchMaintenanceHistoryOutput{}, err
|
||||||
|
}
|
||||||
|
if logs == nil {
|
||||||
|
logs = []ext.MaintenanceLogWithTask{}
|
||||||
|
}
|
||||||
|
return nil, SearchMaintenanceHistoryOutput{Logs: logs}, nil
|
||||||
|
}
|
||||||
210
internal/tools/meals.go
Normal file
210
internal/tools/meals.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MealsTool struct {
|
||||||
|
store *store.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMealsTool(db *store.DB) *MealsTool {
|
||||||
|
return &MealsTool{store: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add_recipe
|
||||||
|
|
||||||
|
type AddRecipeInput struct {
|
||||||
|
Name string `json:"name" jsonschema:"recipe name"`
|
||||||
|
Cuisine string `json:"cuisine,omitempty"`
|
||||||
|
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||||
|
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||||
|
Servings *int `json:"servings,omitempty"`
|
||||||
|
Ingredients []ext.Ingredient `json:"ingredients,omitempty" jsonschema:"list of ingredients with name, quantity, unit"`
|
||||||
|
Instructions []string `json:"instructions,omitempty" jsonschema:"ordered list of steps"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Rating *int `json:"rating,omitempty" jsonschema:"1-5 rating"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddRecipeOutput struct {
|
||||||
|
Recipe ext.Recipe `json:"recipe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) AddRecipe(ctx context.Context, _ *mcp.CallToolRequest, in AddRecipeInput) (*mcp.CallToolResult, AddRecipeOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, AddRecipeOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
if in.Ingredients == nil {
|
||||||
|
in.Ingredients = []ext.Ingredient{}
|
||||||
|
}
|
||||||
|
if in.Instructions == nil {
|
||||||
|
in.Instructions = []string{}
|
||||||
|
}
|
||||||
|
if in.Tags == nil {
|
||||||
|
in.Tags = []string{}
|
||||||
|
}
|
||||||
|
recipe, err := t.store.AddRecipe(ctx, ext.Recipe{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Cuisine: strings.TrimSpace(in.Cuisine),
|
||||||
|
PrepTimeMinutes: in.PrepTimeMinutes,
|
||||||
|
CookTimeMinutes: in.CookTimeMinutes,
|
||||||
|
Servings: in.Servings,
|
||||||
|
Ingredients: in.Ingredients,
|
||||||
|
Instructions: in.Instructions,
|
||||||
|
Tags: in.Tags,
|
||||||
|
Rating: in.Rating,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, AddRecipeOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, AddRecipeOutput{Recipe: recipe}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// search_recipes
|
||||||
|
|
||||||
|
type SearchRecipesInput struct {
|
||||||
|
Query string `json:"query,omitempty" jsonschema:"search by recipe name"`
|
||||||
|
Cuisine string `json:"cuisine,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty" jsonschema:"filter by tags (all must match)"`
|
||||||
|
Ingredient string `json:"ingredient,omitempty" jsonschema:"filter by ingredient name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchRecipesOutput struct {
|
||||||
|
Recipes []ext.Recipe `json:"recipes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) SearchRecipes(ctx context.Context, _ *mcp.CallToolRequest, in SearchRecipesInput) (*mcp.CallToolResult, SearchRecipesOutput, error) {
|
||||||
|
recipes, err := t.store.SearchRecipes(ctx, in.Query, in.Cuisine, in.Tags, in.Ingredient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, SearchRecipesOutput{}, err
|
||||||
|
}
|
||||||
|
if recipes == nil {
|
||||||
|
recipes = []ext.Recipe{}
|
||||||
|
}
|
||||||
|
return nil, SearchRecipesOutput{Recipes: recipes}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// update_recipe
|
||||||
|
|
||||||
|
type UpdateRecipeInput struct {
|
||||||
|
ID uuid.UUID `json:"id" jsonschema:"recipe id to update"`
|
||||||
|
Name string `json:"name" jsonschema:"recipe name"`
|
||||||
|
Cuisine string `json:"cuisine,omitempty"`
|
||||||
|
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||||
|
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||||
|
Servings *int `json:"servings,omitempty"`
|
||||||
|
Ingredients []ext.Ingredient `json:"ingredients,omitempty"`
|
||||||
|
Instructions []string `json:"instructions,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Rating *int `json:"rating,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateRecipeOutput struct {
|
||||||
|
Recipe ext.Recipe `json:"recipe"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) UpdateRecipe(ctx context.Context, _ *mcp.CallToolRequest, in UpdateRecipeInput) (*mcp.CallToolResult, UpdateRecipeOutput, error) {
|
||||||
|
if strings.TrimSpace(in.Name) == "" {
|
||||||
|
return nil, UpdateRecipeOutput{}, errInvalidInput("name is required")
|
||||||
|
}
|
||||||
|
if in.Ingredients == nil {
|
||||||
|
in.Ingredients = []ext.Ingredient{}
|
||||||
|
}
|
||||||
|
if in.Instructions == nil {
|
||||||
|
in.Instructions = []string{}
|
||||||
|
}
|
||||||
|
if in.Tags == nil {
|
||||||
|
in.Tags = []string{}
|
||||||
|
}
|
||||||
|
recipe, err := t.store.UpdateRecipe(ctx, in.ID, ext.Recipe{
|
||||||
|
Name: strings.TrimSpace(in.Name),
|
||||||
|
Cuisine: strings.TrimSpace(in.Cuisine),
|
||||||
|
PrepTimeMinutes: in.PrepTimeMinutes,
|
||||||
|
CookTimeMinutes: in.CookTimeMinutes,
|
||||||
|
Servings: in.Servings,
|
||||||
|
Ingredients: in.Ingredients,
|
||||||
|
Instructions: in.Instructions,
|
||||||
|
Tags: in.Tags,
|
||||||
|
Rating: in.Rating,
|
||||||
|
Notes: strings.TrimSpace(in.Notes),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, UpdateRecipeOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, UpdateRecipeOutput{Recipe: recipe}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// create_meal_plan
|
||||||
|
|
||||||
|
type CreateMealPlanInput struct {
|
||||||
|
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to plan"`
|
||||||
|
Meals []ext.MealPlanInput `json:"meals" jsonschema:"list of meal entries for the week"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateMealPlanOutput struct {
|
||||||
|
Entries []ext.MealPlanEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) CreateMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in CreateMealPlanInput) (*mcp.CallToolResult, CreateMealPlanOutput, error) {
|
||||||
|
if len(in.Meals) == 0 {
|
||||||
|
return nil, CreateMealPlanOutput{}, errInvalidInput("meals are required")
|
||||||
|
}
|
||||||
|
entries, err := t.store.CreateMealPlan(ctx, in.WeekStart, in.Meals)
|
||||||
|
if err != nil {
|
||||||
|
return nil, CreateMealPlanOutput{}, err
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []ext.MealPlanEntry{}
|
||||||
|
}
|
||||||
|
return nil, CreateMealPlanOutput{Entries: entries}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_meal_plan
|
||||||
|
|
||||||
|
type GetMealPlanInput struct {
|
||||||
|
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to retrieve"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetMealPlanOutput struct {
|
||||||
|
Entries []ext.MealPlanEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) GetMealPlan(ctx context.Context, _ *mcp.CallToolRequest, in GetMealPlanInput) (*mcp.CallToolResult, GetMealPlanOutput, error) {
|
||||||
|
entries, err := t.store.GetMealPlan(ctx, in.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GetMealPlanOutput{}, err
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []ext.MealPlanEntry{}
|
||||||
|
}
|
||||||
|
return nil, GetMealPlanOutput{Entries: entries}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate_shopping_list
|
||||||
|
|
||||||
|
type GenerateShoppingListInput struct {
|
||||||
|
WeekStart time.Time `json:"week_start" jsonschema:"Monday of the week to generate shopping list for"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenerateShoppingListOutput struct {
|
||||||
|
ShoppingList ext.ShoppingList `json:"shopping_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MealsTool) GenerateShoppingList(ctx context.Context, _ *mcp.CallToolRequest, in GenerateShoppingListInput) (*mcp.CallToolResult, GenerateShoppingListOutput, error) {
|
||||||
|
list, err := t.store.GenerateShoppingList(ctx, in.WeekStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, GenerateShoppingListOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, GenerateShoppingListOutput{ShoppingList: list}, nil
|
||||||
|
}
|
||||||
163
internal/tools/reparse_metadata.go
Normal file
163
internal/tools/reparse_metadata.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
"golang.org/x/sync/semaphore"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/metadata"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/session"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const metadataReparseConcurrency = 4
|
||||||
|
|
||||||
|
type ReparseMetadataTool struct {
|
||||||
|
store *store.DB
|
||||||
|
provider ai.Provider
|
||||||
|
capture config.CaptureConfig
|
||||||
|
sessions *session.ActiveProjects
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReparseMetadataInput struct {
|
||||||
|
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope the reparse"`
|
||||||
|
Limit int `json:"limit,omitempty" jsonschema:"maximum number of thoughts to process in one call; defaults to 100"`
|
||||||
|
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"whether to include archived thoughts; defaults to false"`
|
||||||
|
OlderThanDays int `json:"older_than_days,omitempty" jsonschema:"only reparse thoughts older than N days; 0 means no restriction"`
|
||||||
|
DryRun bool `json:"dry_run,omitempty" jsonschema:"report counts without updating metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReparseMetadataFailure struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReparseMetadataOutput struct {
|
||||||
|
Scanned int `json:"scanned"`
|
||||||
|
Reparsed int `json:"reparsed"`
|
||||||
|
Normalized int `json:"normalized"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
Failures []ReparseMetadataFailure `json:"failures,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReparseMetadataTool(db *store.DB, provider ai.Provider, capture config.CaptureConfig, sessions *session.ActiveProjects, logger *slog.Logger) *ReparseMetadataTool {
|
||||||
|
return &ReparseMetadataTool{store: db, provider: provider, capture: capture, sessions: sessions, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReparseMetadataTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in ReparseMetadataInput) (*mcp.CallToolResult, ReparseMetadataOutput, error) {
|
||||||
|
limit := in.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ReparseMetadataOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectID *uuid.UUID
|
||||||
|
if project != nil {
|
||||||
|
projectID = &project.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughts, err := t.store.ListThoughtsForMetadataReparse(ctx, limit, projectID, in.IncludeArchived, in.OlderThanDays)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ReparseMetadataOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := ReparseMetadataOutput{
|
||||||
|
Scanned: len(thoughts),
|
||||||
|
DryRun: in.DryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.DryRun || len(thoughts) == 0 {
|
||||||
|
return nil, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
sem := semaphore.NewWeighted(metadataReparseConcurrency)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, thought := range thoughts {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := sem.Acquire(ctx, 1); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(thought thoughttypes.Thought) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer sem.Release(1)
|
||||||
|
|
||||||
|
normalizedCurrent := metadata.Normalize(thought.Metadata, t.capture)
|
||||||
|
|
||||||
|
extracted, extractErr := t.provider.ExtractMetadata(ctx, thought.Content)
|
||||||
|
normalizedTarget := normalizedCurrent
|
||||||
|
if extractErr != nil {
|
||||||
|
mu.Lock()
|
||||||
|
out.Normalized++
|
||||||
|
mu.Unlock()
|
||||||
|
t.logger.Warn("metadata reparse extract failed, using normalized existing metadata", slog.String("thought_id", thought.ID.String()), slog.String("error", extractErr.Error()))
|
||||||
|
} else {
|
||||||
|
normalizedTarget = metadata.Normalize(extracted, t.capture)
|
||||||
|
mu.Lock()
|
||||||
|
out.Reparsed++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadataEqual(thought.Metadata, normalizedTarget) {
|
||||||
|
mu.Lock()
|
||||||
|
out.Skipped++
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, updateErr := t.store.UpdateThought(ctx, thought.ID, thought.Content, nil, "", normalizedTarget, thought.ProjectID); updateErr != nil {
|
||||||
|
mu.Lock()
|
||||||
|
out.Failures = append(out.Failures, ReparseMetadataFailure{ID: thought.ID.String(), Error: updateErr.Error()})
|
||||||
|
mu.Unlock()
|
||||||
|
t.logger.Warn("metadata reparse update failed", slog.String("thought_id", thought.ID.String()), slog.String("error", updateErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
out.Updated++
|
||||||
|
mu.Unlock()
|
||||||
|
}(thought)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
out.Failed = len(out.Failures)
|
||||||
|
|
||||||
|
t.logger.Info("metadata reparse completed",
|
||||||
|
slog.Int("scanned", out.Scanned),
|
||||||
|
slog.Int("reparsed", out.Reparsed),
|
||||||
|
slog.Int("normalized", out.Normalized),
|
||||||
|
slog.Int("updated", out.Updated),
|
||||||
|
slog.Int("skipped", out.Skipped),
|
||||||
|
slog.Int("failed", out.Failed),
|
||||||
|
slog.Duration("duration", time.Since(start)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataEqual(a, b thoughttypes.ThoughtMetadata) bool {
|
||||||
|
return reflect.DeepEqual(a, b)
|
||||||
|
}
|
||||||
215
internal/types/extensions.go
Normal file
215
internal/types/extensions.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Household Knowledge
|
||||||
|
|
||||||
|
type HouseholdItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Details map[string]any `json:"details"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HouseholdVendor struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ServiceType string `json:"service_type,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Website string `json:"website,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
Rating *int `json:"rating,omitempty"`
|
||||||
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home Maintenance
|
||||||
|
|
||||||
|
type MaintenanceTask struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
FrequencyDays *int `json:"frequency_days,omitempty"`
|
||||||
|
LastCompleted *time.Time `json:"last_completed,omitempty"`
|
||||||
|
NextDue *time.Time `json:"next_due,omitempty"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaintenanceLog struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
PerformedBy string `json:"performed_by,omitempty"`
|
||||||
|
Cost *float64 `json:"cost,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
NextAction string `json:"next_action,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaintenanceLogWithTask struct {
|
||||||
|
MaintenanceLog
|
||||||
|
TaskName string `json:"task_name"`
|
||||||
|
TaskCategory string `json:"task_category,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family Calendar
|
||||||
|
|
||||||
|
type FamilyMember struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Relationship string `json:"relationship,omitempty"`
|
||||||
|
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Activity struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||||
|
MemberName string `json:"member_name,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ActivityType string `json:"activity_type,omitempty"`
|
||||||
|
DayOfWeek string `json:"day_of_week,omitempty"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
StartDate *time.Time `json:"start_date,omitempty"`
|
||||||
|
EndDate *time.Time `json:"end_date,omitempty"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportantDate struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
FamilyMemberID *uuid.UUID `json:"family_member_id,omitempty"`
|
||||||
|
MemberName string `json:"member_name,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
DateValue time.Time `json:"date_value"`
|
||||||
|
RecurringYearly bool `json:"recurring_yearly"`
|
||||||
|
ReminderDaysBefore int `json:"reminder_days_before"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meal Planning
|
||||||
|
|
||||||
|
type Ingredient struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity string `json:"quantity,omitempty"`
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Recipe struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cuisine string `json:"cuisine,omitempty"`
|
||||||
|
PrepTimeMinutes *int `json:"prep_time_minutes,omitempty"`
|
||||||
|
CookTimeMinutes *int `json:"cook_time_minutes,omitempty"`
|
||||||
|
Servings *int `json:"servings,omitempty"`
|
||||||
|
Ingredients []Ingredient `json:"ingredients"`
|
||||||
|
Instructions []string `json:"instructions"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Rating *int `json:"rating,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MealPlanEntry struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
WeekStart time.Time `json:"week_start"`
|
||||||
|
DayOfWeek string `json:"day_of_week"`
|
||||||
|
MealType string `json:"meal_type"`
|
||||||
|
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
|
||||||
|
RecipeName string `json:"recipe_name,omitempty"`
|
||||||
|
CustomMeal string `json:"custom_meal,omitempty"`
|
||||||
|
Servings *int `json:"servings,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MealPlanInput struct {
|
||||||
|
DayOfWeek string `json:"day_of_week" jsonschema:"day of week (monday-sunday)"`
|
||||||
|
MealType string `json:"meal_type" jsonschema:"one of: breakfast, lunch, dinner, snack"`
|
||||||
|
RecipeID *uuid.UUID `json:"recipe_id,omitempty" jsonschema:"optional recipe id"`
|
||||||
|
CustomMeal string `json:"custom_meal,omitempty" jsonschema:"optional free-text meal description when no recipe"`
|
||||||
|
Servings *int `json:"servings,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShoppingItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity string `json:"quantity,omitempty"`
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Purchased bool `json:"purchased"`
|
||||||
|
RecipeID *uuid.UUID `json:"recipe_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShoppingList struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
WeekStart time.Time `json:"week_start"`
|
||||||
|
Items []ShoppingItem `json:"items"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Professional CRM
|
||||||
|
|
||||||
|
type ProfessionalContact struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Company string `json:"company,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
LinkedInURL string `json:"linkedin_url,omitempty"`
|
||||||
|
HowWeMet string `json:"how_we_met,omitempty"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
LastContacted *time.Time `json:"last_contacted,omitempty"`
|
||||||
|
FollowUpDate *time.Time `json:"follow_up_date,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactInteraction struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ContactID uuid.UUID `json:"contact_id"`
|
||||||
|
InteractionType string `json:"interaction_type"`
|
||||||
|
OccurredAt time.Time `json:"occurred_at"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
FollowUpNeeded bool `json:"follow_up_needed"`
|
||||||
|
FollowUpNotes string `json:"follow_up_notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opportunity struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Value *float64 `json:"value,omitempty"`
|
||||||
|
ExpectedCloseDate *time.Time `json:"expected_close_date,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactHistory struct {
|
||||||
|
Contact ProfessionalContact `json:"contact"`
|
||||||
|
Interactions []ContactInteraction `json:"interactions"`
|
||||||
|
Opportunities []Opportunity `json:"opportunities"`
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
42
migrations/011_household_knowledge.sql
Normal file
42
migrations/011_household_knowledge.sql
Normal file
@@ -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();
|
||||||
56
migrations/012_home_maintenance.sql
Normal file
56
migrations/012_home_maintenance.sql
Normal file
@@ -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();
|
||||||
42
migrations/013_family_calendar.sql
Normal file
42
migrations/013_family_calendar.sql
Normal file
@@ -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);
|
||||||
54
migrations/014_meal_planning.sql
Normal file
54
migrations/014_meal_planning.sql
Normal file
@@ -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();
|
||||||
71
migrations/015_professional_crm.sql
Normal file
71
migrations/015_professional_crm.sql
Normal file
@@ -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();
|
||||||
31
migrations/100_rls_and_grants.sql
Normal file
31
migrations/100_rls_and_grants.sql
Normal file
@@ -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;
|
||||||
Reference in New Issue
Block a user