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:
210
internal/store/calendar.go
Normal file
210
internal/store/calendar.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func (db *DB) AddFamilyMember(ctx context.Context, m ext.FamilyMember) (ext.FamilyMember, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into family_members (name, relationship, birth_date, notes)
|
||||
values ($1, $2, $3, $4)
|
||||
returning id, created_at
|
||||
`, m.Name, nullStr(m.Relationship), m.BirthDate, nullStr(m.Notes))
|
||||
|
||||
created := m
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.FamilyMember{}, fmt.Errorf("insert family member: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListFamilyMembers(ctx context.Context) ([]ext.FamilyMember, error) {
|
||||
rows, err := db.pool.Query(ctx, `select id, name, relationship, birth_date, notes, created_at from family_members order by name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list family members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []ext.FamilyMember
|
||||
for rows.Next() {
|
||||
var m ext.FamilyMember
|
||||
var relationship, notes *string
|
||||
if err := rows.Scan(&m.ID, &m.Name, &relationship, &m.BirthDate, ¬es, &m.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan family member: %w", err)
|
||||
}
|
||||
m.Relationship = strVal(relationship)
|
||||
m.Notes = strVal(notes)
|
||||
members = append(members, m)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) AddActivity(ctx context.Context, a ext.Activity) (ext.Activity, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into activities (family_member_id, title, activity_type, day_of_week, start_time, end_time, start_date, end_date, location, notes)
|
||||
values ($1, $2, $3, $4, $5::time, $6::time, $7, $8, $9, $10)
|
||||
returning id, created_at
|
||||
`, a.FamilyMemberID, a.Title, nullStr(a.ActivityType), nullStr(a.DayOfWeek),
|
||||
nullStr(a.StartTime), nullStr(a.EndTime), a.StartDate, a.EndDate,
|
||||
nullStr(a.Location), nullStr(a.Notes))
|
||||
|
||||
created := a
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.Activity{}, fmt.Errorf("insert activity: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetWeekSchedule(ctx context.Context, weekStart time.Time) ([]ext.Activity, error) {
|
||||
weekEnd := weekStart.AddDate(0, 0, 7)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
from activities a
|
||||
left join family_members fm on fm.id = a.family_member_id
|
||||
where (a.start_date >= $1 and a.start_date < $2)
|
||||
or (a.day_of_week is not null and (a.end_date is null or a.end_date >= $1))
|
||||
order by a.start_date, a.start_time
|
||||
`, weekStart, weekEnd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get week schedule: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanActivities(rows)
|
||||
}
|
||||
|
||||
func (db *DB) SearchActivities(ctx context.Context, query, activityType string, memberID *uuid.UUID) ([]ext.Activity, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(a.title ILIKE $%d OR a.notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if t := strings.TrimSpace(activityType); t != "" {
|
||||
args = append(args, t)
|
||||
conditions = append(conditions, fmt.Sprintf("a.activity_type = $%d", len(args)))
|
||||
}
|
||||
if memberID != nil {
|
||||
args = append(args, *memberID)
|
||||
conditions = append(conditions, fmt.Sprintf("a.family_member_id = $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `
|
||||
select a.id, a.family_member_id, fm.name, a.title, a.activity_type,
|
||||
a.day_of_week, a.start_time::text, a.end_time::text,
|
||||
a.start_date, a.end_date, a.location, a.notes, a.created_at
|
||||
from activities a
|
||||
left join family_members fm on fm.id = a.family_member_id
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by a.start_date, a.start_time"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search activities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanActivities(rows)
|
||||
}
|
||||
|
||||
func (db *DB) AddImportantDate(ctx context.Context, d ext.ImportantDate) (ext.ImportantDate, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into important_dates (family_member_id, title, date_value, recurring_yearly, reminder_days_before, notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at
|
||||
`, d.FamilyMemberID, d.Title, d.DateValue, d.RecurringYearly, d.ReminderDaysBefore, nullStr(d.Notes))
|
||||
|
||||
created := d
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.ImportantDate{}, fmt.Errorf("insert important date: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUpcomingDates(ctx context.Context, daysAhead int) ([]ext.ImportantDate, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 30
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.AddDate(0, 0, daysAhead)
|
||||
|
||||
// For yearly recurring events, check if this year's occurrence falls in range
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select d.id, d.family_member_id, fm.name, d.title, d.date_value,
|
||||
d.recurring_yearly, d.reminder_days_before, d.notes, d.created_at
|
||||
from important_dates d
|
||||
left join family_members fm on fm.id = d.family_member_id
|
||||
where (
|
||||
(d.recurring_yearly = false and d.date_value between $1 and $2)
|
||||
or
|
||||
(d.recurring_yearly = true and
|
||||
make_date(extract(year from now())::int, extract(month from d.date_value)::int, extract(day from d.date_value)::int)
|
||||
between $1 and $2)
|
||||
)
|
||||
order by d.date_value
|
||||
`, now, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upcoming dates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var dates []ext.ImportantDate
|
||||
for rows.Next() {
|
||||
var d ext.ImportantDate
|
||||
var memberID *uuid.UUID
|
||||
var memberName, notes *string
|
||||
if err := rows.Scan(&d.ID, &memberID, &memberName, &d.Title, &d.DateValue,
|
||||
&d.RecurringYearly, &d.ReminderDaysBefore, ¬es, &d.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan important date: %w", err)
|
||||
}
|
||||
d.FamilyMemberID = memberID
|
||||
d.MemberName = strVal(memberName)
|
||||
d.Notes = strVal(notes)
|
||||
dates = append(dates, d)
|
||||
}
|
||||
return dates, rows.Err()
|
||||
}
|
||||
|
||||
func scanActivities(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.Activity, error) {
|
||||
defer rows.Close()
|
||||
var activities []ext.Activity
|
||||
for rows.Next() {
|
||||
var a ext.Activity
|
||||
var memberName, activityType, dayOfWeek, startTime, endTime, location, notes *string
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.FamilyMemberID, &memberName, &a.Title, &activityType,
|
||||
&dayOfWeek, &startTime, &endTime,
|
||||
&a.StartDate, &a.EndDate, &location, ¬es, &a.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan activity: %w", err)
|
||||
}
|
||||
a.MemberName = strVal(memberName)
|
||||
a.ActivityType = strVal(activityType)
|
||||
a.DayOfWeek = strVal(dayOfWeek)
|
||||
a.StartTime = strVal(startTime)
|
||||
a.EndTime = strVal(endTime)
|
||||
a.Location = strVal(location)
|
||||
a.Notes = strVal(notes)
|
||||
activities = append(activities, a)
|
||||
}
|
||||
return activities, rows.Err()
|
||||
}
|
||||
247
internal/store/crm.go
Normal file
247
internal/store/crm.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func (db *DB) AddProfessionalContact(ctx context.Context, c ext.ProfessionalContact) (ext.ProfessionalContact, error) {
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into professional_contacts (name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, follow_up_date)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
returning id, created_at, updated_at
|
||||
`, c.Name, nullStr(c.Company), nullStr(c.Title), nullStr(c.Email), nullStr(c.Phone),
|
||||
nullStr(c.LinkedInURL), nullStr(c.HowWeMet), c.Tags, nullStr(c.Notes), c.FollowUpDate)
|
||||
|
||||
created := c
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("insert contact: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) SearchContacts(ctx context.Context, query string, tags []string) ([]ext.ProfessionalContact, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
idx := len(args)
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"(name ILIKE $%[1]d OR company ILIKE $%[1]d OR title ILIKE $%[1]d OR notes ILIKE $%[1]d)", idx))
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
args = append(args, tags)
|
||||
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at from professional_contacts`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search contacts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanContacts(rows)
|
||||
}
|
||||
|
||||
func (db *DB) GetContact(ctx context.Context, id uuid.UUID) (ext.ProfessionalContact, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
from professional_contacts where id = $1
|
||||
`, id)
|
||||
|
||||
var c ext.ProfessionalContact
|
||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||
if err := row.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return ext.ProfessionalContact{}, fmt.Errorf("get contact: %w", err)
|
||||
}
|
||||
c.Company = strVal(company)
|
||||
c.Title = strVal(title)
|
||||
c.Email = strVal(email)
|
||||
c.Phone = strVal(phone)
|
||||
c.LinkedInURL = strVal(linkedInURL)
|
||||
c.HowWeMet = strVal(howWeMet)
|
||||
c.Notes = strVal(notes)
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (db *DB) LogInteraction(ctx context.Context, interaction ext.ContactInteraction) (ext.ContactInteraction, error) {
|
||||
occurredAt := interaction.OccurredAt
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into contact_interactions (contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at
|
||||
`, interaction.ContactID, interaction.InteractionType, occurredAt, interaction.Summary,
|
||||
interaction.FollowUpNeeded, nullStr(interaction.FollowUpNotes))
|
||||
|
||||
created := interaction
|
||||
created.OccurredAt = occurredAt
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.ContactInteraction{}, fmt.Errorf("insert interaction: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetContactHistory(ctx context.Context, contactID uuid.UUID) (ext.ContactHistory, error) {
|
||||
contact, err := db.GetContact(ctx, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, contact_id, interaction_type, occurred_at, summary, follow_up_needed, follow_up_notes, created_at
|
||||
from contact_interactions where contact_id = $1 order by occurred_at desc
|
||||
`, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("get interactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var interactions []ext.ContactInteraction
|
||||
for rows.Next() {
|
||||
var i ext.ContactInteraction
|
||||
var followUpNotes *string
|
||||
if err := rows.Scan(&i.ID, &i.ContactID, &i.InteractionType, &i.OccurredAt, &i.Summary,
|
||||
&i.FollowUpNeeded, &followUpNotes, &i.CreatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan interaction: %w", err)
|
||||
}
|
||||
i.FollowUpNotes = strVal(followUpNotes)
|
||||
interactions = append(interactions, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
|
||||
oppRows, err := db.pool.Query(ctx, `
|
||||
select id, contact_id, title, description, stage, value, expected_close_date, notes, created_at, updated_at
|
||||
from opportunities where contact_id = $1 order by created_at desc
|
||||
`, contactID)
|
||||
if err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("get opportunities: %w", err)
|
||||
}
|
||||
defer oppRows.Close()
|
||||
|
||||
var opportunities []ext.Opportunity
|
||||
for oppRows.Next() {
|
||||
var o ext.Opportunity
|
||||
var description, notes *string
|
||||
if err := oppRows.Scan(&o.ID, &o.ContactID, &o.Title, &description, &o.Stage, &o.Value,
|
||||
&o.ExpectedCloseDate, ¬es, &o.CreatedAt, &o.UpdatedAt); err != nil {
|
||||
return ext.ContactHistory{}, fmt.Errorf("scan opportunity: %w", err)
|
||||
}
|
||||
o.Description = strVal(description)
|
||||
o.Notes = strVal(notes)
|
||||
opportunities = append(opportunities, o)
|
||||
}
|
||||
if err := oppRows.Err(); err != nil {
|
||||
return ext.ContactHistory{}, err
|
||||
}
|
||||
|
||||
return ext.ContactHistory{
|
||||
Contact: contact,
|
||||
Interactions: interactions,
|
||||
Opportunities: opportunities,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateOpportunity(ctx context.Context, o ext.Opportunity) (ext.Opportunity, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into opportunities (contact_id, title, description, stage, value, expected_close_date, notes)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
returning id, created_at, updated_at
|
||||
`, o.ContactID, o.Title, nullStr(o.Description), o.Stage, o.Value, o.ExpectedCloseDate, nullStr(o.Notes))
|
||||
|
||||
created := o
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.Opportunity{}, fmt.Errorf("insert opportunity: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetFollowUpsDue(ctx context.Context, daysAhead int) ([]ext.ProfessionalContact, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 7
|
||||
}
|
||||
cutoff := time.Now().AddDate(0, 0, daysAhead)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, name, company, title, email, phone, linkedin_url, how_we_met, tags, notes, last_contacted, follow_up_date, created_at, updated_at
|
||||
from professional_contacts
|
||||
where follow_up_date <= $1
|
||||
order by follow_up_date asc
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get follow-ups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanContacts(rows)
|
||||
}
|
||||
|
||||
func (db *DB) AppendThoughtToContactNotes(ctx context.Context, contactID uuid.UUID, thoughtContent string) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
update professional_contacts
|
||||
set notes = coalesce(notes, '') || $2
|
||||
where id = $1
|
||||
`, contactID, thoughtContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("append thought to contact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanContacts(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.ProfessionalContact, error) {
|
||||
defer rows.Close()
|
||||
var contacts []ext.ProfessionalContact
|
||||
for rows.Next() {
|
||||
var c ext.ProfessionalContact
|
||||
var company, title, email, phone, linkedInURL, howWeMet, notes *string
|
||||
if err := rows.Scan(&c.ID, &c.Name, &company, &title, &email, &phone,
|
||||
&linkedInURL, &howWeMet, &c.Tags, ¬es, &c.LastContacted, &c.FollowUpDate,
|
||||
&c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan contact: %w", err)
|
||||
}
|
||||
c.Company = strVal(company)
|
||||
c.Title = strVal(title)
|
||||
c.Email = strVal(email)
|
||||
c.Phone = strVal(phone)
|
||||
c.LinkedInURL = strVal(linkedInURL)
|
||||
c.HowWeMet = strVal(howWeMet)
|
||||
c.Notes = strVal(notes)
|
||||
if c.Tags == nil {
|
||||
c.Tags = []string{}
|
||||
}
|
||||
contacts = append(contacts, c)
|
||||
}
|
||||
return contacts, rows.Err()
|
||||
}
|
||||
15
internal/store/helpers.go
Normal file
15
internal/store/helpers.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package store
|
||||
|
||||
func nullStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func strVal(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
150
internal/store/household.go
Normal file
150
internal/store/household.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func (db *DB) AddHouseholdItem(ctx context.Context, item ext.HouseholdItem) (ext.HouseholdItem, error) {
|
||||
details, err := json.Marshal(item.Details)
|
||||
if err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("marshal details: %w", err)
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into household_items (name, category, location, details, notes)
|
||||
values ($1, $2, $3, $4::jsonb, $5)
|
||||
returning id, created_at, updated_at
|
||||
`, item.Name, nullStr(item.Category), nullStr(item.Location), details, nullStr(item.Notes))
|
||||
|
||||
created := item
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("insert household item: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) SearchHouseholdItems(ctx context.Context, query, category, location string) ([]ext.HouseholdItem, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d OR notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(category); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("category = $%d", len(args)))
|
||||
}
|
||||
if l := strings.TrimSpace(location); l != "" {
|
||||
args = append(args, "%"+l+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("location ILIKE $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `select id, name, category, location, details, notes, created_at, updated_at from household_items`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search household items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ext.HouseholdItem
|
||||
for rows.Next() {
|
||||
var item ext.HouseholdItem
|
||||
var detailsBytes []byte
|
||||
var category, location, notes *string
|
||||
if err := rows.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan household item: %w", err)
|
||||
}
|
||||
item.Category = strVal(category)
|
||||
item.Location = strVal(location)
|
||||
item.Notes = strVal(notes)
|
||||
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||
item.Details = map[string]any{}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetHouseholdItem(ctx context.Context, id uuid.UUID) (ext.HouseholdItem, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, category, location, details, notes, created_at, updated_at
|
||||
from household_items where id = $1
|
||||
`, id)
|
||||
|
||||
var item ext.HouseholdItem
|
||||
var detailsBytes []byte
|
||||
var category, location, notes *string
|
||||
if err := row.Scan(&item.ID, &item.Name, &category, &location, &detailsBytes, ¬es, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return ext.HouseholdItem{}, fmt.Errorf("get household item: %w", err)
|
||||
}
|
||||
item.Category = strVal(category)
|
||||
item.Location = strVal(location)
|
||||
item.Notes = strVal(notes)
|
||||
if err := json.Unmarshal(detailsBytes, &item.Details); err != nil {
|
||||
item.Details = map[string]any{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (db *DB) AddVendor(ctx context.Context, v ext.HouseholdVendor) (ext.HouseholdVendor, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into household_vendors (name, service_type, phone, email, website, notes, rating, last_used)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
returning id, created_at
|
||||
`, v.Name, nullStr(v.ServiceType), nullStr(v.Phone), nullStr(v.Email),
|
||||
nullStr(v.Website), nullStr(v.Notes), v.Rating, v.LastUsed)
|
||||
|
||||
created := v
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt); err != nil {
|
||||
return ext.HouseholdVendor{}, fmt.Errorf("insert vendor: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListVendors(ctx context.Context, serviceType string) ([]ext.HouseholdVendor, error) {
|
||||
args := []any{}
|
||||
q := `select id, name, service_type, phone, email, website, notes, rating, last_used, created_at from household_vendors`
|
||||
if st := strings.TrimSpace(serviceType); st != "" {
|
||||
args = append(args, st)
|
||||
q += " where service_type = $1"
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list vendors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vendors []ext.HouseholdVendor
|
||||
for rows.Next() {
|
||||
var v ext.HouseholdVendor
|
||||
var serviceType, phone, email, website, notes *string
|
||||
var lastUsed *time.Time
|
||||
if err := rows.Scan(&v.ID, &v.Name, &serviceType, &phone, &email, &website, ¬es, &v.Rating, &lastUsed, &v.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan vendor: %w", err)
|
||||
}
|
||||
v.ServiceType = strVal(serviceType)
|
||||
v.Phone = strVal(phone)
|
||||
v.Email = strVal(email)
|
||||
v.Website = strVal(website)
|
||||
v.Notes = strVal(notes)
|
||||
v.LastUsed = lastUsed
|
||||
vendors = append(vendors, v)
|
||||
}
|
||||
return vendors, rows.Err()
|
||||
}
|
||||
142
internal/store/maintenance.go
Normal file
142
internal/store/maintenance.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func (db *DB) AddMaintenanceTask(ctx context.Context, t ext.MaintenanceTask) (ext.MaintenanceTask, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into maintenance_tasks (name, category, frequency_days, next_due, priority, notes)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, created_at, updated_at
|
||||
`, t.Name, nullStr(t.Category), t.FrequencyDays, t.NextDue, t.Priority, nullStr(t.Notes))
|
||||
|
||||
created := t
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.MaintenanceTask{}, fmt.Errorf("insert maintenance task: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) LogMaintenance(ctx context.Context, log ext.MaintenanceLog) (ext.MaintenanceLog, error) {
|
||||
completedAt := log.CompletedAt
|
||||
if completedAt.IsZero() {
|
||||
completedAt = time.Now()
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into maintenance_logs (task_id, completed_at, performed_by, cost, notes, next_action)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id
|
||||
`, log.TaskID, completedAt, nullStr(log.PerformedBy), log.Cost, nullStr(log.Notes), nullStr(log.NextAction))
|
||||
|
||||
created := log
|
||||
created.CompletedAt = completedAt
|
||||
if err := row.Scan(&created.ID); err != nil {
|
||||
return ext.MaintenanceLog{}, fmt.Errorf("insert maintenance log: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUpcomingMaintenance(ctx context.Context, daysAhead int) ([]ext.MaintenanceTask, error) {
|
||||
if daysAhead <= 0 {
|
||||
daysAhead = 30
|
||||
}
|
||||
cutoff := time.Now().Add(time.Duration(daysAhead) * 24 * time.Hour)
|
||||
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select id, name, category, frequency_days, last_completed, next_due, priority, notes, created_at, updated_at
|
||||
from maintenance_tasks
|
||||
where next_due <= $1 or next_due is null
|
||||
order by next_due asc nulls last, priority desc
|
||||
`, cutoff)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get upcoming maintenance: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanMaintenanceTasks(rows)
|
||||
}
|
||||
|
||||
func (db *DB) SearchMaintenanceHistory(ctx context.Context, query, category string, start, end *time.Time) ([]ext.MaintenanceLogWithTask, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("(mt.name ILIKE $%d OR ml.notes ILIKE $%d)", len(args), len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(category); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("mt.category = $%d", len(args)))
|
||||
}
|
||||
if start != nil {
|
||||
args = append(args, *start)
|
||||
conditions = append(conditions, fmt.Sprintf("ml.completed_at >= $%d", len(args)))
|
||||
}
|
||||
if end != nil {
|
||||
args = append(args, *end)
|
||||
conditions = append(conditions, fmt.Sprintf("ml.completed_at <= $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `
|
||||
select ml.id, ml.task_id, ml.completed_at, ml.performed_by, ml.cost, ml.notes, ml.next_action,
|
||||
mt.name, mt.category
|
||||
from maintenance_logs ml
|
||||
join maintenance_tasks mt on mt.id = ml.task_id
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by ml.completed_at desc"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search maintenance history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []ext.MaintenanceLogWithTask
|
||||
for rows.Next() {
|
||||
var l ext.MaintenanceLogWithTask
|
||||
var performedBy, notes, nextAction, taskCategory *string
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.TaskID, &l.CompletedAt, &performedBy, &l.Cost, ¬es, &nextAction,
|
||||
&l.TaskName, &taskCategory,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance log: %w", err)
|
||||
}
|
||||
l.PerformedBy = strVal(performedBy)
|
||||
l.Notes = strVal(notes)
|
||||
l.NextAction = strVal(nextAction)
|
||||
l.TaskCategory = strVal(taskCategory)
|
||||
logs = append(logs, l)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
func scanMaintenanceTasks(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
Close()
|
||||
}) ([]ext.MaintenanceTask, error) {
|
||||
defer rows.Close()
|
||||
var tasks []ext.MaintenanceTask
|
||||
for rows.Next() {
|
||||
var t ext.MaintenanceTask
|
||||
var category, notes *string
|
||||
if err := rows.Scan(&t.ID, &t.Name, &category, &t.FrequencyDays, &t.LastCompleted, &t.NextDue, &t.Priority, ¬es, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan maintenance task: %w", err)
|
||||
}
|
||||
t.Category = strVal(category)
|
||||
t.Notes = strVal(notes)
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
return tasks, rows.Err()
|
||||
}
|
||||
289
internal/store/meals.go
Normal file
289
internal/store/meals.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||
)
|
||||
|
||||
func (db *DB) AddRecipe(ctx context.Context, r ext.Recipe) (ext.Recipe, error) {
|
||||
ingredients, err := json.Marshal(r.Ingredients)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
}
|
||||
instructions, err := json.Marshal(r.Instructions)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
}
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into recipes (name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes)
|
||||
values ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10)
|
||||
returning id, created_at, updated_at
|
||||
`, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
|
||||
created := r
|
||||
if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("insert recipe: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (db *DB) SearchRecipes(ctx context.Context, query, cuisine string, tags []string, ingredient string) ([]ext.Recipe, error) {
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
|
||||
if q := strings.TrimSpace(query); q != "" {
|
||||
args = append(args, "%"+q+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("name ILIKE $%d", len(args)))
|
||||
}
|
||||
if c := strings.TrimSpace(cuisine); c != "" {
|
||||
args = append(args, c)
|
||||
conditions = append(conditions, fmt.Sprintf("cuisine = $%d", len(args)))
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
args = append(args, tags)
|
||||
conditions = append(conditions, fmt.Sprintf("tags @> $%d", len(args)))
|
||||
}
|
||||
if ing := strings.TrimSpace(ingredient); ing != "" {
|
||||
args = append(args, "%"+ing+"%")
|
||||
conditions = append(conditions, fmt.Sprintf("ingredients::text ILIKE $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at from recipes`
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
rows, err := db.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search recipes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var recipes []ext.Recipe
|
||||
for rows.Next() {
|
||||
r, err := scanRecipeRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipes = append(recipes, r)
|
||||
}
|
||||
return recipes, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetRecipe(ctx context.Context, id uuid.UUID) (ext.Recipe, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, cuisine, prep_time_minutes, cook_time_minutes, servings, ingredients, instructions, tags, rating, notes, created_at, updated_at
|
||||
from recipes where id = $1
|
||||
`, id)
|
||||
|
||||
var r ext.Recipe
|
||||
var cuisine, notes *string
|
||||
var ingredientsBytes, instructionsBytes []byte
|
||||
if err := row.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("get recipe: %w", err)
|
||||
}
|
||||
r.Cuisine = strVal(cuisine)
|
||||
r.Notes = strVal(notes)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||
r.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||
r.Instructions = []string{}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateRecipe(ctx context.Context, id uuid.UUID, r ext.Recipe) (ext.Recipe, error) {
|
||||
ingredients, err := json.Marshal(r.Ingredients)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal ingredients: %w", err)
|
||||
}
|
||||
instructions, err := json.Marshal(r.Instructions)
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("marshal instructions: %w", err)
|
||||
}
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
|
||||
_, err = db.pool.Exec(ctx, `
|
||||
update recipes set
|
||||
name = $2, cuisine = $3, prep_time_minutes = $4, cook_time_minutes = $5,
|
||||
servings = $6, ingredients = $7::jsonb, instructions = $8::jsonb,
|
||||
tags = $9, rating = $10, notes = $11
|
||||
where id = $1
|
||||
`, id, r.Name, nullStr(r.Cuisine), r.PrepTimeMinutes, r.CookTimeMinutes, r.Servings,
|
||||
ingredients, instructions, r.Tags, r.Rating, nullStr(r.Notes))
|
||||
if err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("update recipe: %w", err)
|
||||
}
|
||||
return db.GetRecipe(ctx, id)
|
||||
}
|
||||
|
||||
func (db *DB) CreateMealPlan(ctx context.Context, weekStart time.Time, entries []ext.MealPlanInput) ([]ext.MealPlanEntry, error) {
|
||||
if _, err := db.pool.Exec(ctx, `delete from meal_plans where week_start = $1`, weekStart); err != nil {
|
||||
return nil, fmt.Errorf("clear meal plan: %w", err)
|
||||
}
|
||||
|
||||
var results []ext.MealPlanEntry
|
||||
for _, e := range entries {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into meal_plans (week_start, day_of_week, meal_type, recipe_id, custom_meal, servings, notes)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)
|
||||
returning id, created_at
|
||||
`, weekStart, e.DayOfWeek, e.MealType, e.RecipeID, nullStr(e.CustomMeal), e.Servings, nullStr(e.Notes))
|
||||
|
||||
entry := ext.MealPlanEntry{
|
||||
WeekStart: weekStart,
|
||||
DayOfWeek: e.DayOfWeek,
|
||||
MealType: e.MealType,
|
||||
RecipeID: e.RecipeID,
|
||||
CustomMeal: e.CustomMeal,
|
||||
Servings: e.Servings,
|
||||
Notes: e.Notes,
|
||||
}
|
||||
if err := row.Scan(&entry.ID, &entry.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("insert meal plan entry: %w", err)
|
||||
}
|
||||
results = append(results, entry)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetMealPlan(ctx context.Context, weekStart time.Time) ([]ext.MealPlanEntry, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select mp.id, mp.week_start, mp.day_of_week, mp.meal_type, mp.recipe_id, r.name, mp.custom_meal, mp.servings, mp.notes, mp.created_at
|
||||
from meal_plans mp
|
||||
left join recipes r on r.id = mp.recipe_id
|
||||
where mp.week_start = $1
|
||||
order by
|
||||
case mp.day_of_week
|
||||
when 'monday' then 1 when 'tuesday' then 2 when 'wednesday' then 3
|
||||
when 'thursday' then 4 when 'friday' then 5 when 'saturday' then 6
|
||||
when 'sunday' then 7 else 8
|
||||
end,
|
||||
case mp.meal_type
|
||||
when 'breakfast' then 1 when 'lunch' then 2 when 'dinner' then 3
|
||||
when 'snack' then 4 else 5
|
||||
end
|
||||
`, weekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get meal plan: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []ext.MealPlanEntry
|
||||
for rows.Next() {
|
||||
var e ext.MealPlanEntry
|
||||
var recipeName, customMeal, notes *string
|
||||
if err := rows.Scan(&e.ID, &e.WeekStart, &e.DayOfWeek, &e.MealType, &e.RecipeID, &recipeName, &customMeal, &e.Servings, ¬es, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan meal plan entry: %w", err)
|
||||
}
|
||||
e.RecipeName = strVal(recipeName)
|
||||
e.CustomMeal = strVal(customMeal)
|
||||
e.Notes = strVal(notes)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GenerateShoppingList(ctx context.Context, weekStart time.Time) (ext.ShoppingList, error) {
|
||||
entries, err := db.GetMealPlan(ctx, weekStart)
|
||||
if err != nil {
|
||||
return ext.ShoppingList{}, err
|
||||
}
|
||||
|
||||
recipeIDs := map[uuid.UUID]bool{}
|
||||
for _, e := range entries {
|
||||
if e.RecipeID != nil {
|
||||
recipeIDs[*e.RecipeID] = true
|
||||
}
|
||||
}
|
||||
|
||||
aggregated := map[string]*ext.ShoppingItem{}
|
||||
for id := range recipeIDs {
|
||||
recipe, err := db.GetRecipe(ctx, id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, ing := range recipe.Ingredients {
|
||||
key := strings.ToLower(ing.Name)
|
||||
if existing, ok := aggregated[key]; ok {
|
||||
if ing.Quantity != "" {
|
||||
existing.Quantity += "+" + ing.Quantity
|
||||
}
|
||||
} else {
|
||||
recipeIDCopy := id
|
||||
aggregated[key] = &ext.ShoppingItem{
|
||||
Name: ing.Name,
|
||||
Quantity: ing.Quantity,
|
||||
Unit: ing.Unit,
|
||||
Purchased: false,
|
||||
RecipeID: &recipeIDCopy,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]ext.ShoppingItem, 0, len(aggregated))
|
||||
for _, item := range aggregated {
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
itemsJSON, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return ext.ShoppingList{}, fmt.Errorf("marshal shopping items: %w", err)
|
||||
}
|
||||
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into shopping_lists (week_start, items)
|
||||
values ($1, $2::jsonb)
|
||||
on conflict (week_start) do update set items = excluded.items, updated_at = now()
|
||||
returning id, created_at, updated_at
|
||||
`, weekStart, itemsJSON)
|
||||
|
||||
list := ext.ShoppingList{WeekStart: weekStart, Items: items}
|
||||
if err := row.Scan(&list.ID, &list.CreatedAt, &list.UpdatedAt); err != nil {
|
||||
return ext.ShoppingList{}, fmt.Errorf("upsert shopping list: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func scanRecipeRow(rows interface{ Scan(...any) error }) (ext.Recipe, error) {
|
||||
var r ext.Recipe
|
||||
var cuisine, notes *string
|
||||
var ingredientsBytes, instructionsBytes []byte
|
||||
if err := rows.Scan(&r.ID, &r.Name, &cuisine, &r.PrepTimeMinutes, &r.CookTimeMinutes, &r.Servings,
|
||||
&ingredientsBytes, &instructionsBytes, &r.Tags, &r.Rating, ¬es, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return ext.Recipe{}, fmt.Errorf("scan recipe: %w", err)
|
||||
}
|
||||
r.Cuisine = strVal(cuisine)
|
||||
r.Notes = strVal(notes)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
if err := json.Unmarshal(ingredientsBytes, &r.Ingredients); err != nil {
|
||||
r.Ingredients = []ext.Ingredient{}
|
||||
}
|
||||
if err := json.Unmarshal(instructionsBytes, &r.Instructions); err != nil {
|
||||
r.Instructions = []string{}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -428,6 +428,57 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
|
||||
return thoughts, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *uuid.UUID, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||
args := make([]any, 0, 3)
|
||||
conditions := make([]string, 0, 4)
|
||||
|
||||
if !includeArchived {
|
||||
conditions = append(conditions, "archived_at is null")
|
||||
}
|
||||
if projectID != nil {
|
||||
args = append(args, *projectID)
|
||||
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
|
||||
}
|
||||
if olderThanDays > 0 {
|
||||
args = append(args, time.Now().Add(-time.Duration(olderThanDays)*24*time.Hour))
|
||||
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)))
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
query := `
|
||||
select guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||
from thoughts
|
||||
`
|
||||
if len(conditions) > 0 {
|
||||
query += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
query += " order by created_at asc limit $" + fmt.Sprintf("%d", len(args))
|
||||
|
||||
rows, err := db.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list thoughts for metadata reparse: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
thoughts := make([]thoughttypes.Thought, 0, limit)
|
||||
for rows.Next() {
|
||||
var thought thoughttypes.Thought
|
||||
var metadataBytes []byte
|
||||
if err := rows.Scan(&thought.ID, &thought.Content, &metadataBytes, &thought.ProjectID, &thought.ArchivedAt, &thought.CreatedAt, &thought.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan metadata-reparse thought: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(metadataBytes, &thought.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("decode metadata-reparse thought metadata: %w", err)
|
||||
}
|
||||
thoughts = append(thoughts, thought)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate metadata-reparse thoughts: %w", err)
|
||||
}
|
||||
|
||||
return thoughts, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertEmbedding(ctx context.Context, thoughtID uuid.UUID, model string, embedding []float32) error {
|
||||
_, err := db.pool.Exec(ctx, `
|
||||
insert into embeddings (thought_id, model, dim, embedding)
|
||||
|
||||
Reference in New Issue
Block a user