diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 082788c..863ba40 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -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 diff --git a/apps/finance/services/api/main/handler_org.go b/apps/finance/services/api/main/handler_org.go index 14118a7..eb71fe2 100644 --- a/apps/finance/services/api/main/handler_org.go +++ b/apps/finance/services/api/main/handler_org.go @@ -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) diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index ecb3b8e..854b293 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -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 } diff --git a/apps/finance/services/api/main/models_org.go b/apps/finance/services/api/main/models_org.go index d321898..1a076d3 100644 --- a/apps/finance/services/api/main/models_org.go +++ b/apps/finance/services/api/main/models_org.go @@ -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"` diff --git a/apps/finance/services/api/main/store_org.go b/apps/finance/services/api/main/store_org.go index 51a8ed0..eb5d443 100644 --- a/apps/finance/services/api/main/store_org.go +++ b/apps/finance/services/api/main/store_org.go @@ -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() diff --git a/apps/finance/services/api/main/templates/org_event_detail.html b/apps/finance/services/api/main/templates/org_event_detail.html index 6366987..0498ce3 100644 --- a/apps/finance/services/api/main/templates/org_event_detail.html +++ b/apps/finance/services/api/main/templates/org_event_detail.html @@ -127,12 +127,46 @@

{{$d.Event.Description}}

{{end}} - {{if $d.Event.Goals}}
-
GOALS
-

{{$d.Event.Goals}}

+
GOALS
+ {{if $d.Event.GoalItems}} + + {{else}} +

No goals yet.

+ {{end}} + {{if ne (print $d.Event.Status) "approved"}} +
+ + +
+ {{end}}
- {{end}}