- domain: Garden gains a computed MyRole ("owner"/"editor"/"viewer"); new
ShareWithUser (share + recipient identity); sentinels ErrShareUserNotFound,
ErrCannotShareWithSelf, ErrShareExists.
- store/shares.go: GetShareRole, ListSharesForGarden (joined with users),
CreateShare (UNIQUE → ErrShareExists), UpdateShareRole, DeleteShare.
- store/gardens.go: ListGardensForActor returns owned + shared-with-me gardens,
each carrying my_role (replaces the owner-only list).
- service: requireGardenRole now consults garden_shares (owner implicit, else the
share's role, else masked ErrNotFound) and stamps MyRole on every read-through.
UpdateGarden tightened to OWNER-only (editors edit contents, not the garden).
UpdatePlanting only re-checks plant visibility when the plant is CHANGED, so a
shared editor can edit a plop that uses the owner's private plant.
- service/shares.go: ListShares/AddShare/UpdateShareRole (owner only), RemoveShare
(owner or a recipient leaving). AddShare targets an existing account by exact
email; unknown → 404, self → 400, duplicate → 409.
- api: GET,POST /gardens/:id/shares and PATCH,DELETE /gardens/:id/shares/:userId.
Tests: the full {owner, editor, viewer, stranger} × {read, mutate object, mutate
planting, edit garden, delete garden, manage shares} ACL matrix; shared gardens
carry my_role; AddShare error cases; role upgrade + self-leave; plus a two-user
HTTP flow. The matrix caught the editor-cannot-edit-owner's-plant bug above.
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
78 lines
2.8 KiB
Go
78 lines
2.8 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"). Any other actor gets the garden's
|
|
// masked ErrNotFound (existence isn't revealed to non-participants).
|
|
func (s *Service) RemoveShare(ctx context.Context, actorID, gardenID, targetUserID int64) error {
|
|
g, err := s.store.GetGarden(ctx, gardenID)
|
|
if err != nil {
|
|
return err // ErrNotFound
|
|
}
|
|
if g.OwnerID != actorID && actorID != targetUserID {
|
|
return domain.ErrNotFound
|
|
}
|
|
// 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)
|
|
}
|