feat: add checkable goals list to org events (#24)
Goals are stored as EventGoal items embedded in the event document. During active fiscal years, members can check/uncheck goals inline. Goals can be added and deleted while the event is not yet approved. Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
26a7236494
commit
1fce3b36aa
@ -284,6 +284,9 @@ type storeIface interface {
|
||||
createEvent(ctx context.Context, e *OrgEvent) error
|
||||
updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error
|
||||
deleteEvent(ctx context.Context, eventID, orgID string) error
|
||||
addGoalItem(ctx context.Context, eventID, orgID string, goal EventGoal) error
|
||||
toggleGoalItem(ctx context.Context, eventID, orgID, goalID string, done bool, doneBy string) error
|
||||
deleteGoalItem(ctx context.Context, eventID, orgID, goalID string) error
|
||||
getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error)
|
||||
createBudgetLine(ctx context.Context, l *BudgetLine) error
|
||||
deleteBudgetLine(ctx context.Context, lineID, orgID string) error
|
||||
|
||||
@ -891,6 +891,77 @@ func (h *Handler) OrgEventFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
// ── Goal items ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) OrgGoalAdd(w http.ResponseWriter, r *http.Request) {
|
||||
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||
ctx := r.Context()
|
||||
yearID := r.PathValue("year_id")
|
||||
eventID := r.PathValue("event_id")
|
||||
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
http.Error(w, "goal text required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
goal := EventGoal{
|
||||
ID: bson.NewObjectID().Hex(),
|
||||
Text: text,
|
||||
}
|
||||
if err := h.store.addGoalItem(ctx, eventID, org.ID, goal); err != nil {
|
||||
slog.Error("add goal item", "err", err)
|
||||
http.Error(w, "could not add goal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) OrgGoalToggle(w http.ResponseWriter, r *http.Request) {
|
||||
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||
ctx := r.Context()
|
||||
yearID := r.PathValue("year_id")
|
||||
eventID := r.PathValue("event_id")
|
||||
goalID := r.PathValue("goal_id")
|
||||
|
||||
// Only allow toggling when fiscal year is active
|
||||
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
|
||||
if err != nil || year.Status != FiscalYearActive {
|
||||
http.Error(w, "goals can only be checked during an active fiscal year", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
done := r.FormValue("done") == "1"
|
||||
if err := h.store.toggleGoalItem(ctx, eventID, org.ID, goalID, done, me.ID); err != nil {
|
||||
slog.Error("toggle goal item", "err", err)
|
||||
http.Error(w, "could not update goal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) OrgGoalDelete(w http.ResponseWriter, r *http.Request) {
|
||||
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
|
||||
ctx := r.Context()
|
||||
yearID := r.PathValue("year_id")
|
||||
eventID := r.PathValue("event_id")
|
||||
goalID := r.PathValue("goal_id")
|
||||
|
||||
ev, err := h.store.getEvent(ctx, eventID, org.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "event not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if ev.Status == EventApproved {
|
||||
http.Error(w, "cannot remove goals from an approved event", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
_ = h.store.deleteGoalItem(ctx, eventID, org.ID, goalID)
|
||||
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
// ── Budget lines ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) OrgBudgetLineCreate(w http.ResponseWriter, r *http.Request) {
|
||||
@ -1866,6 +1937,9 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/feedback", h.OrgEventFeedback)
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget", h.OrgBudgetLineCreate)
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget/{line_id}/delete", h.OrgBudgetLineDelete)
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals", h.OrgGoalAdd)
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals/{goal_id}/toggle", h.OrgGoalToggle)
|
||||
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals/{goal_id}/delete", h.OrgGoalDelete)
|
||||
|
||||
// Analysis & report
|
||||
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/analysis", h.OrgAnalysis)
|
||||
|
||||
@ -207,6 +207,11 @@ func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error)
|
||||
func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil }
|
||||
func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||
func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil }
|
||||
func (m *mockStore) addGoalItem(_ context.Context, _, _ string, _ EventGoal) error { return nil }
|
||||
func (m *mockStore) toggleGoalItem(_ context.Context, _, _, _ string, _ bool, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockStore) deleteGoalItem(_ context.Context, _, _, _ string) error { return nil }
|
||||
func (m *mockStore) getBudgetLines(_ context.Context, _, _ string) ([]BudgetLine, error) { return nil, nil }
|
||||
func (m *mockStore) createBudgetLine(_ context.Context, _ *BudgetLine) error { return nil }
|
||||
func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
@ -100,6 +100,16 @@ const (
|
||||
EventRejected EventStatus = "rejected"
|
||||
)
|
||||
|
||||
// EventGoal is a single checkable item in an event's goal list.
|
||||
// Done can be toggled by any org member while the fiscal year is active.
|
||||
type EventGoal struct {
|
||||
ID string `bson:"id" json:"id"`
|
||||
Text string `bson:"text" json:"text"`
|
||||
Done bool `bson:"done" json:"done"`
|
||||
DoneBy string `bson:"done_by,omitempty" json:"done_by,omitempty"`
|
||||
DoneAt time.Time `bson:"done_at,omitempty" json:"done_at,omitempty"`
|
||||
}
|
||||
|
||||
type OrgEvent struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
OrgID string `bson:"org_id" json:"org_id"`
|
||||
@ -108,6 +118,7 @@ type OrgEvent struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
Goals string `bson:"goals" json:"goals"`
|
||||
GoalItems []EventGoal `bson:"goal_items" json:"goal_items"`
|
||||
DateStart time.Time `bson:"date_start" json:"date_start"`
|
||||
DateEnd time.Time `bson:"date_end" json:"date_end"`
|
||||
Status EventStatus `bson:"status" json:"status"`
|
||||
|
||||
@ -406,6 +406,47 @@ func (s *Store) updateEvent(ctx context.Context, eventID, orgID string, update b
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) addGoalItem(ctx context.Context, eventID, orgID string, goal EventGoal) error {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.addGoalItem")
|
||||
defer span.End()
|
||||
_, err := s.orgEvents().UpdateOne(ctx,
|
||||
bson.M{"_id": eventID, "org_id": orgID},
|
||||
bson.M{"$push": bson.M{"goal_items": goal}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) toggleGoalItem(ctx context.Context, eventID, orgID, goalID string, done bool, doneBy string) error {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.toggleGoalItem")
|
||||
defer span.End()
|
||||
set := bson.M{
|
||||
"goal_items.$[g].done": done,
|
||||
"goal_items.$[g].done_by": doneBy,
|
||||
}
|
||||
if done {
|
||||
set["goal_items.$[g].done_at"] = time.Now()
|
||||
} else {
|
||||
set["goal_items.$[g].done_at"] = time.Time{}
|
||||
set["goal_items.$[g].done_by"] = ""
|
||||
}
|
||||
_, err := s.orgEvents().UpdateOne(ctx,
|
||||
bson.M{"_id": eventID, "org_id": orgID},
|
||||
bson.M{"$set": set},
|
||||
options.UpdateOne().SetArrayFilters([]any{bson.M{"g.id": goalID}}),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) deleteGoalItem(ctx context.Context, eventID, orgID, goalID string) error {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoalItem")
|
||||
defer span.End()
|
||||
_, err := s.orgEvents().UpdateOne(ctx,
|
||||
bson.M{"_id": eventID, "org_id": orgID},
|
||||
bson.M{"$pull": bson.M{"goal_items": bson.M{"id": goalID}}},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent")
|
||||
defer span.End()
|
||||
|
||||
@ -127,12 +127,46 @@
|
||||
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Description}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if $d.Event.Goals}}
|
||||
<div>
|
||||
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:6px;">GOALS</div>
|
||||
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Goals}}</p>
|
||||
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:8px;">GOALS</div>
|
||||
{{if $d.Event.GoalItems}}
|
||||
<ul style="list-style:none; padding:0; margin:0 0 10px;">
|
||||
{{range $d.Event.GoalItems}}
|
||||
{{$goal := .}}
|
||||
<li style="display:flex; align-items:center; gap:8px; padding:5px 0; border-bottom:1px solid var(--border2);">
|
||||
{{if eq (print $d.FiscalYear.Status) "active"}}
|
||||
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals/{{$goal.ID}}/toggle" style="display:contents;">
|
||||
{{if $goal.Done}}
|
||||
<input type="hidden" name="done" value="0">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0;color:var(--accent);" title="Mark undone">✅</button>
|
||||
{{else}}
|
||||
<input type="hidden" name="done" value="1">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0;color:var(--text3);" title="Mark done">⬜</button>
|
||||
{{end}}
|
||||
</form>
|
||||
{{else}}
|
||||
{{if $goal.Done}}<span>✅</span>{{else}}<span style="color:var(--text3);">⬜</span>{{end}}
|
||||
{{end}}
|
||||
<span style="flex:1; font-size:13px; {{if $goal.Done}}text-decoration:line-through; color:var(--text3);{{end}}">{{$goal.Text}}</span>
|
||||
{{if $goal.Done}}<span style="font-size:11px; color:var(--text3);">by {{$goal.DoneBy}}</span>{{end}}
|
||||
{{if and (ne (print $d.Event.Status) "approved") (not $goal.Done)}}
|
||||
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals/{{$goal.ID}}/delete">
|
||||
<button type="submit" style="background:none;border:none;cursor:pointer;font-size:12px;color:var(--text3);" title="Remove goal">✕</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p style="font-size:13px; color:var(--text3); margin:0 0 10px;">No goals yet.</p>
|
||||
{{end}}
|
||||
{{if ne (print $d.Event.Status) "approved"}}
|
||||
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals" style="display:flex; gap:8px;">
|
||||
<input name="text" placeholder="Add a goal…" style="flex:1;" required>
|
||||
<button type="submit" class="btn btn-outline btn-sm">Add</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Budget lines -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user