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:
2026-03-26 23:29:03 +02:00
parent b74d63c543
commit 0eb6ac7ee5
25 changed files with 2910 additions and 10 deletions

210
internal/store/calendar.go Normal file
View 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, &notes, &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, &notes, &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, &notes, &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
View 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, &notes, &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, &notes, &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, &notes, &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
View 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
View 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, &notes, &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, &notes, &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, &notes, &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()
}

View 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, &notes, &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, &notes, &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
View 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, &notes, &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, &notes, &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, &notes, &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
}

View File

@@ -428,6 +428,57 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
return thoughts, nil
}
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *uuid.UUID, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
args := make([]any, 0, 3)
conditions := make([]string, 0, 4)
if !includeArchived {
conditions = append(conditions, "archived_at is null")
}
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
}
if olderThanDays > 0 {
args = append(args, time.Now().Add(-time.Duration(olderThanDays)*24*time.Hour))
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)))
}
args = append(args, limit)
query := `
select guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
`
if len(conditions) > 0 {
query += " where " + strings.Join(conditions, " and ")
}
query += " order by created_at asc limit $" + fmt.Sprintf("%d", len(args))
rows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list thoughts for metadata reparse: %w", err)
}
defer rows.Close()
thoughts := make([]thoughttypes.Thought, 0, limit)
for rows.Next() {
var thought thoughttypes.Thought
var metadataBytes []byte
if err := rows.Scan(&thought.ID, &thought.Content, &metadataBytes, &thought.ProjectID, &thought.ArchivedAt, &thought.CreatedAt, &thought.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan metadata-reparse thought: %w", err)
}
if err := json.Unmarshal(metadataBytes, &thought.Metadata); err != nil {
return nil, fmt.Errorf("decode metadata-reparse thought metadata: %w", err)
}
thoughts = append(thoughts, thought)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate metadata-reparse thoughts: %w", err)
}
return thoughts, nil
}
func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error {
_, err := db.pool.Exec(ctx, `
insert into embeddings (thought_id, model, dim, embedding)