Files
pansy/internal/service/shares.go
T
steveandClaude Opus 4.8 873c591635
Build image / build-and-push (push) Successful in 5s
Address Gadfly review on #16: RemoveShare choke point + dedup
- RemoveShare now routes through requireGardenRole(roleViewer) — the standard
  authorization choke point (masks existence for non-participants) — then applies
  the owner-or-self rule on top (a participant removing someone else's share is
  now ErrForbidden, not ErrNotFound). No more bespoke GetGarden+manual check.
- Add domain.RoleOwner constant; gardenRole.String() and the tests use it instead
  of the bare "owner" literal.
- UpdatePlanting fetches the plant once and only re-checks visibility when the
  plant id actually CHANGES (new ≠ old), so a no-op plantId resend can't break a
  shared editor editing a plop that uses the owner's private plant.
- Bound ListSharesForGarden with a LIMIT backstop.

Skipped: AddShare email-existence disclosure (the issue explicitly accepts it as
inherent to email-based sharing), 404-on-missing-share (correct DELETE
semantics), and the join-scan/test-helper dedup nits.

GOWORK=off go build/vet/test ./internal/... green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 23:48:20 -04:00

80 lines
3.0 KiB
Go

package service
import (
"context"
"errors"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// isShareRole reports whether role is a grantable share role (owner is implicit,
// never a share row).
func isShareRole(role string) bool {
return role == domain.RoleViewer || role == domain.RoleEditor
}
// ListShares returns a garden's shares (each with the recipient's identity).
// Owner only — sharing is managed by the owner alone.
func (s *Service) ListShares(ctx context.Context, actorID, gardenID int64) ([]domain.ShareWithUser, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
return nil, err
}
return s.store.ListSharesForGarden(ctx, gardenID)
}
// AddShare grants a user viewer/editor access to a garden, targeting them by the
// exact email of an existing account (v1 has no invitation emails). Owner only.
// Unknown email → ErrShareUserNotFound; the owner's own email →
// ErrCannotShareWithSelf; an already-shared user → ErrShareExists.
func (s *Service) AddShare(ctx context.Context, actorID, gardenID int64, email, role string) (*domain.GardenShare, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
return nil, err
}
if !isShareRole(role) {
return nil, domain.ErrInvalidInput
}
target, err := s.store.GetUserByEmail(ctx, strings.TrimSpace(email))
if errors.Is(err, domain.ErrNotFound) {
return nil, domain.ErrShareUserNotFound
}
if err != nil {
return nil, err
}
if target.ID == actorID {
return nil, domain.ErrCannotShareWithSelf
}
return s.store.CreateShare(ctx, &domain.GardenShare{
GardenID: gardenID, UserID: target.ID, Role: role, CreatedBy: actorID,
})
}
// UpdateShareRole changes an existing share's role. Owner only.
func (s *Service) UpdateShareRole(ctx context.Context, actorID, gardenID, targetUserID int64, role string) (*domain.GardenShare, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil {
return nil, err
}
if !isShareRole(role) {
return nil, domain.ErrInvalidInput
}
return s.store.UpdateShareRole(ctx, gardenID, targetUserID, role)
}
// RemoveShare revokes a share. The garden owner may remove anyone; a recipient
// may remove themselves ("leave garden"). It routes through requireGardenRole
// (roleViewer) so a non-participant gets the standard masked ErrNotFound, then
// applies the owner-or-self rule on top (a participant removing someone else's
// share is ErrForbidden — they can already see the garden).
func (s *Service) RemoveShare(ctx context.Context, actorID, gardenID, targetUserID int64) error {
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
if err != nil {
return err // ErrNotFound for a non-participant
}
if g.OwnerID != actorID && actorID != targetUserID {
return domain.ErrForbidden
}
// Owner path removes any share; self-leave removes the actor's own (a missing
// row is ErrNotFound either way).
return s.store.DeleteShare(ctx, gardenID, targetUserID)
}