package domain import ( "errors" "time" "github.com/google/uuid" ) // Role is the trio of collaborator permissions on an event. Comparisons are // done with the numeric `rank()` rather than string equality so handlers can // say `requireRole(RoleEditor)` and accept both editor and owner. type Role string const ( RoleOwner Role = "owner" RoleEditor Role = "editor" RoleViewer Role = "viewer" ) // Valid reports whether r is one of the three known roles. func (r Role) Valid() bool { switch r { case RoleOwner, RoleEditor, RoleViewer: return true } return false } // rank returns a comparable integer: higher = more privilege. // Owner=30 / Editor=20 / Viewer=10 leaves room for future intermediate // roles (e.g. "guest-manager" at 15) without renumbering callers. func (r Role) rank() int { switch r { case RoleOwner: return 30 case RoleEditor: return 20 case RoleViewer: return 10 } return 0 } // AtLeast reports whether r is at least as privileged as min. // requireRole helpers compare with `r.AtLeast(RoleEditor)`. func (r Role) AtLeast(min Role) bool { return r.rank() >= min.rank() } // Collaborator is one user's membership on an event. AcceptedAt is nil while // the invite is still pending — those rows are surfaced separately, not via // this struct (see CollaboratorInvite). type Collaborator struct { EventID uuid.UUID `json:"event_id"` UserID uuid.UUID `json:"user_id"` Role Role `json:"role"` InvitedBy *uuid.UUID `json:"invited_by,omitempty"` InvitedAt time.Time `json:"invited_at"` AcceptedAt *time.Time `json:"accepted_at,omitempty"` // Display fields joined from users — populated by List, omitted by point // lookups that don't need them. Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` } // CollaboratorInvite is a pending invitation that hasn't been accepted yet. // The raw token lives only in the email link; what we store is its SHA-256 // hash, so a database leak doesn't hand attackers usable invites. type CollaboratorInvite struct { EventID uuid.UUID `json:"event_id"` Email string `json:"email"` Role Role `json:"role"` InvitedBy uuid.UUID `json:"invited_by"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` } // DefaultInviteTTL is how long a fresh collaborator invitation stays valid. // Seven days mirrors the password-reset flow's expiry. Resend mints a new // token rather than extending an old one. const DefaultInviteTTL = 7 * 24 * time.Hour var ( ErrCollaboratorNotFound = errors.New("collaborator not found") ErrCollaboratorExists = errors.New("user is already a collaborator") ErrLastOwner = errors.New("cannot remove the last owner") ErrInviteNotFound = errors.New("invitation not found") ErrInviteExpired = errors.New("invitation expired") ErrInviteAlreadyConsumed = errors.New("invitation already used") ErrInviteEmailMismatch = errors.New("invitation was sent to a different email") )