Skip to content

Commit dcee0a9

Browse files
Add visibility + role-based access fields with validation for events
1 parent 1d07c6a commit dcee0a9

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

pkg/event/event.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
package event
22

3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
const (
9+
StatusDraft = "draft"
10+
StatusPublished = "published"
11+
StatusClosed = "closed"
12+
)
13+
14+
const (
15+
VisibilityPublic = "public"
16+
VisibilityPrivate = "private"
17+
)
18+
19+
const (
20+
RoleMember = "member"
21+
RoleOfficer = "officer"
22+
RoleAdmin = "admin"
23+
)
24+
325
type AnswerDetails struct {
426
MaxChars int `bson:"max_chars,omitempty" json:"max_chars,omitempty"`
527
}
@@ -29,6 +51,8 @@ type Event struct {
2951
MaxAttendees int `bson:"max_attendees" json:"max_attendees"`
3052
CreatedAt string `bson:"created_at" json:"created_at"`
3153
Status string `bson:"status" json:"status"` // draft, published, closed
54+
Visibility string `bson:"visibility" json:"visibility"` // public, private
55+
MinimumVisibleRole string `bson:"minimum_visible_role,omitempty" json:"minimum_visible_role,omitempty"`
3256
}
3357

3458
// IsAdmin checks if the given userID is in the event's Admins list.
@@ -41,6 +65,210 @@ func (e *Event) IsAdmin(userID string) bool {
4165
return false
4266
}
4367

68+
// ApplyDefaults sets safe defaults for newly created events.
69+
func (e *Event) ApplyDefaults() {
70+
if strings.TrimSpace(e.Status) == "" {
71+
e.Status = StatusDraft
72+
}
73+
74+
if strings.TrimSpace(e.Visibility) == "" {
75+
e.Visibility = VisibilityPublic
76+
}
77+
78+
// public events should not carry a minimum visible role
79+
if e.Visibility == VisibilityPublic {
80+
e.MinimumVisibleRole = ""
81+
}
82+
}
83+
84+
// Validate validates the event shape for create/replace scenarios.
85+
func (e *Event) Validate() error {
86+
if strings.TrimSpace(e.Name) == "" {
87+
return fmt.Errorf("name is required")
88+
}
89+
90+
if strings.TrimSpace(e.Date) == "" {
91+
return fmt.Errorf("date is required")
92+
}
93+
94+
if strings.TrimSpace(e.Time) == "" {
95+
return fmt.Errorf("time is required")
96+
}
97+
98+
if strings.TrimSpace(e.Location) == "" {
99+
return fmt.Errorf("location is required")
100+
}
101+
102+
if err := ValidateStatus(e.Status); err != nil {
103+
return err
104+
}
105+
106+
if err := ValidateVisibility(e.Visibility); err != nil {
107+
return err
108+
}
109+
110+
if err := ValidateMinimumVisibleRole(e.Visibility, e.MinimumVisibleRole); err != nil {
111+
return err
112+
}
113+
114+
if e.MaxAttendees < 0 {
115+
return fmt.Errorf("max_attendees cannot be negative")
116+
}
117+
118+
return nil
119+
}
120+
121+
func ValidateStatus(status string) error {
122+
switch status {
123+
case StatusDraft, StatusPublished, StatusClosed:
124+
return nil
125+
default:
126+
return fmt.Errorf("invalid status: must be one of draft, published, closed")
127+
}
128+
}
129+
130+
func ValidateVisibility(visibility string) error {
131+
switch visibility {
132+
case VisibilityPublic, VisibilityPrivate:
133+
return nil
134+
default:
135+
return fmt.Errorf("invalid visibility: must be one of public, private")
136+
}
137+
}
138+
139+
func ValidateRole(role string) error {
140+
switch role {
141+
case RoleMember, RoleOfficer, RoleAdmin:
142+
return nil
143+
default:
144+
return fmt.Errorf("invalid minimum_visible_role: must be one of member, officer, admin")
145+
}
146+
}
147+
148+
func ValidateMinimumVisibleRole(visibility string, role string) error {
149+
switch visibility {
150+
case VisibilityPublic:
151+
if strings.TrimSpace(role) != "" {
152+
return fmt.Errorf("minimum_visible_role must be empty when visibility is public")
153+
}
154+
return nil
155+
156+
case VisibilityPrivate:
157+
if strings.TrimSpace(role) == "" {
158+
return fmt.Errorf("minimum_visible_role is required when visibility is private")
159+
}
160+
return ValidateRole(role)
161+
162+
default:
163+
return fmt.Errorf("invalid visibility: must be one of public, private")
164+
}
165+
}
166+
167+
// ValidateUpdateFields validates only the fields present in a PATCH payload.
168+
func ValidateUpdateFields(fields map[string]interface{}) error {
169+
var (
170+
statusProvided bool
171+
visibilityProvided bool
172+
roleProvided bool
173+
174+
status string
175+
visibility string
176+
role string
177+
)
178+
179+
for key, value := range fields {
180+
switch key {
181+
case "status":
182+
s, ok := value.(string)
183+
if !ok {
184+
return fmt.Errorf("status must be a string")
185+
}
186+
statusProvided = true
187+
status = strings.TrimSpace(s)
188+
if err := ValidateStatus(status); err != nil {
189+
return err
190+
}
191+
192+
case "visibility":
193+
s, ok := value.(string)
194+
if !ok {
195+
return fmt.Errorf("visibility must be a string")
196+
}
197+
visibilityProvided = true
198+
visibility = strings.TrimSpace(s)
199+
if err := ValidateVisibility(visibility); err != nil {
200+
return err
201+
}
202+
203+
case "minimum_visible_role":
204+
s, ok := value.(string)
205+
if !ok {
206+
return fmt.Errorf("minimum_visible_role must be a string")
207+
}
208+
roleProvided = true
209+
role = strings.TrimSpace(s)
210+
211+
case "max_attendees":
212+
// JSON numbers come in as float64 when bound into map[string]interface{}
213+
n, ok := value.(float64)
214+
if !ok {
215+
return fmt.Errorf("max_attendees must be a number")
216+
}
217+
if n < 0 {
218+
return fmt.Errorf("max_attendees cannot be negative")
219+
}
220+
221+
case "name", "date", "time", "location", "description":
222+
s, ok := value.(string)
223+
if !ok {
224+
return fmt.Errorf("%s must be a string", key)
225+
}
226+
if key != "description" && strings.TrimSpace(s) == "" {
227+
return fmt.Errorf("%s cannot be empty", key)
228+
}
229+
}
230+
}
231+
232+
// Cross-field validation only when one of these fields is being updated.
233+
if visibilityProvided || roleProvided {
234+
if !visibilityProvided {
235+
vRaw, ok := fields["visibility"]
236+
if ok {
237+
v, ok := vRaw.(string)
238+
if !ok {
239+
return fmt.Errorf("visibility must be a string")
240+
}
241+
visibility = strings.TrimSpace(v)
242+
}
243+
}
244+
245+
if !roleProvided {
246+
rRaw, ok := fields["minimum_visible_role"]
247+
if ok {
248+
r, ok := rRaw.(string)
249+
if !ok {
250+
return fmt.Errorf("minimum_visible_role must be a string")
251+
}
252+
role = strings.TrimSpace(r)
253+
}
254+
}
255+
256+
// We only fully validate the relationship if visibility is present in the PATCH.
257+
// This avoids breaking partial updates where only unrelated fields are sent.
258+
if visibilityProvided {
259+
if err := ValidateMinimumVisibleRole(visibility, role); err != nil {
260+
return err
261+
}
262+
}
263+
}
264+
265+
if statusProvided && status == "" {
266+
return fmt.Errorf("status cannot be empty")
267+
}
268+
269+
return nil
270+
}
271+
44272
// SanitizeUpdateFields removes immutable fields from an update map
45273
// to prevent them from being overwritten.
46274
func SanitizeUpdateFields(fields map[string]interface{}) {

pkg/handlers/event.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,22 @@ func UpdateEventByID(c *gin.Context) {
135135
return
136136
}
137137

138+
// Validate patch fields for #36 visibility/status support
139+
if err := event.ValidateUpdateFields(fields); err != nil {
140+
c.JSON(http.StatusBadRequest, gin.H{
141+
"error": err.Error(),
142+
})
143+
return
144+
}
145+
146+
// Normalize public events: if visibility is being set to public in PATCH,
147+
// clear minimum_visible_role automatically.
148+
if visibilityRaw, ok := fields["visibility"]; ok {
149+
if visibility, ok := visibilityRaw.(string); ok && strings.TrimSpace(visibility) == event.VisibilityPublic {
150+
fields["minimum_visible_role"] = ""
151+
}
152+
}
153+
138154
err = db.UpdateEventByID(id, fields)
139155
if err != nil {
140156
if err == mongo.ErrNoDocuments {

0 commit comments

Comments
 (0)