package postgres import ( "database/sql" "gardomatic.kleiax.de/internal/storage" ) // GardenModel stores gardens in PostgreSQL. // GardenModel implements storage.GardenModelInterface for PostgreSQL. type GardenModel struct{ DB *sql.DB } // Insert creates a garden and its owner membership atomically. func (m GardenModel) Insert(garden storage.Garden, ownerID int) (storage.Garden, error) { ctx, cancel := contextWithTimeout() defer cancel() tx, err := m.DB.BeginTx(ctx, nil) if err != nil { return storage.Garden{}, err } defer tx.Rollback() query := ` INSERT INTO gardens (name, description, image_data, image_id) VALUES ($1, $2, '', $3) RETURNING id, created_at, updated_at, version` err = tx.QueryRowContext(ctx, query, garden.Name, garden.Description, garden.ImageID).Scan( &garden.ID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, ) if err != nil { return storage.Garden{}, err } _, err = tx.ExecContext(ctx, ` INSERT INTO garden_members (garden_id, user_id, role) VALUES ($1, $2, $3)`, garden.ID, ownerID, storage.GardenRoleOwner) if err != nil { return storage.Garden{}, err } if err = tx.Commit(); err != nil { return storage.Garden{}, err } return garden, nil } func scanGarden(s scanner) (storage.Garden, error) { var garden storage.Garden err := s.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version) return garden, err } // Get returns a garden by ID. func (m GardenModel) Get(id int) (storage.Garden, error) { ctx, cancel := contextWithTimeout() defer cancel() garden, err := scanGarden(m.DB.QueryRowContext(ctx, ` SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version FROM gardens g LEFT JOIN images i ON i.id=g.image_id WHERE g.id = $1`, id)) if err != nil { return storage.Garden{}, recordError(err) } return garden, nil } // GetAllForUser lists gardens visible to a user with resolved permissions. func (m GardenModel) GetAllForUser(userID int) ([]storage.Garden, error) { ctx, cancel := contextWithTimeout() defer cancel() rows, err := m.DB.QueryContext(ctx, ` SELECT g.id, g.name, g.description, COALESCE('data:'||i.media_type||';base64,'||replace(encode(i.data,'base64'), E'\n', ''), ''), g.image_id, g.created_at, g.updated_at, g.version, gm.role FROM gardens g INNER JOIN garden_members gm ON gm.garden_id = g.id LEFT JOIN images i ON i.id=g.image_id WHERE gm.user_id = $1 ORDER BY g.name, g.id`, userID) if err != nil { return nil, err } defer rows.Close() gardens := []storage.Garden{} for rows.Next() { var garden storage.Garden err := rows.Scan(&garden.ID, &garden.Name, &garden.Description, &garden.ImageData, &garden.ImageID, &garden.CreatedAt, &garden.UpdatedAt, &garden.Version, &garden.Role) if err != nil { return nil, err } gardens = append(gardens, garden) } return gardens, rows.Err() } // Update changes a garden using optimistic locking. func (m GardenModel) Update(garden storage.Garden) (storage.Garden, error) { ctx, cancel := contextWithTimeout() defer cancel() err := m.DB.QueryRowContext(ctx, ` UPDATE gardens SET name = $1, description = $2, image_data = '', image_id = $3, updated_at = CURRENT_TIMESTAMP, version = version + 1 WHERE id = $4 AND version = $5 RETURNING updated_at, version`, garden.Name, garden.Description, garden.ImageID, garden.ID, garden.Version, ).Scan(&garden.UpdatedAt, &garden.Version) if err != nil { if err == sql.ErrNoRows { return storage.Garden{}, storage.ErrEditConflict } return storage.Garden{}, err } return garden, nil } // Delete removes a garden and its dependent records. func (m GardenModel) Delete(id int) error { return deleteByID(m.DB, `DELETE FROM gardens WHERE id = $1`, id) }