homelab/apps/finance/services/api/main/handler_org.go
Gonçalo Rodrigues 1fce3b36aa 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>
2026-06-14 16:15:55 +01:00

2081 lines
65 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"crypto/rand"
"encoding/csv"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// slugRe validates org slugs: lowercase letters, digits, hyphens.
var slugRe = regexp.MustCompile(`^[a-z0-9-]{2,40}$`)
// orgTmpl and friends are registered in handler.go parseTmpl block (below).
// ── Middleware helpers ────────────────────────────────────────────────────────
// orgMW loads the org and the caller's membership. Injects them via context values
// wrapped in the request, or returns 403/404. next receives the same request.
//
// It sets two request-scoped values accessible via orgFromCtx / memberFromCtx:
// these are passed as function arguments here for simplicity (no context key needed).
func (h *Handler) requireOrgMember(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
return h.authMW(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
slug := r.PathValue("slug")
org, err := h.store.getOrgBySlug(ctx, slug)
if err != nil {
http.Error(w, "organisation not found", http.StatusNotFound)
return
}
me, err := h.store.getMember(ctx, org.ID, a.UserID)
if err != nil {
http.Error(w, "you are not a member of this organisation", http.StatusForbidden)
return
}
next(w, r, org, me)
})
}
func (h *Handler) requireOrgRole(roles ...OrgRole) func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
return func(next func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember)) http.HandlerFunc {
return h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
for _, role := range roles {
if me.Role == role {
next(w, r, org, me)
return
}
}
http.Error(w, "insufficient permissions", http.StatusForbidden)
})
}
}
// canManageOrg is true for admin and finance.
func canManageOrg(role OrgRole) bool {
return role == OrgRoleAdmin || role == OrgRoleFinance
}
// ── Org list & creation ───────────────────────────────────────────────────────
func (h *Handler) OrgList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
orgs, err := h.store.getOrgsForUser(ctx, a.UserID)
if err != nil {
slog.Error("get orgs for user", "err", err)
orgs = nil
}
render(w, orgListTmpl, &OrgListData{
UserID: a.UserID,
Email: a.Email,
Title: "Organisations",
Route: "orgs",
Orgs: orgs,
})
}
func (h *Handler) OrgCreate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if r.Method == http.MethodGet {
render(w, orgCreateTmpl, map[string]any{
"Title": "New Organisation",
"Route": "orgs",
"UserID": a.UserID,
"Email": a.Email,
})
return
}
name := strings.TrimSpace(r.FormValue("name"))
slug := strings.TrimSpace(strings.ToLower(r.FormValue("slug")))
errMsg := ""
switch {
case name == "":
errMsg = "Name is required."
case !slugRe.MatchString(slug):
errMsg = "Slug must be 240 lowercase letters, digits or hyphens."
}
if errMsg == "" {
exists, _ := h.store.slugExists(ctx, slug)
if exists {
errMsg = "That slug is already taken — choose another."
}
}
if errMsg != "" {
render(w, orgCreateTmpl, map[string]any{
"Title": "New Organisation",
"Route": "orgs",
"UserID": a.UserID,
"Email": a.Email,
"Error": errMsg,
"Name": name,
"Slug": slug,
})
return
}
org := &Org{
ID: bson.NewObjectID().Hex(),
Name: name,
Slug: slug,
OwnerUserID: a.UserID,
CreatedAt: time.Now(),
}
if err := h.store.createOrg(ctx, org); err != nil {
slog.Error("create org", "err", err)
http.Error(w, "could not create organisation", http.StatusInternalServerError)
return
}
// creator becomes admin
member := &OrgMember{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
UserID: a.UserID,
Email: a.Email,
Role: OrgRoleAdmin,
CreatedAt: time.Now(),
}
if err := h.store.createMember(ctx, member); err != nil {
slog.Error("create founding member", "err", err)
}
http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther)
}
// ── Org home ──────────────────────────────────────────────────────────────────
func (h *Handler) OrgHome(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
years, _ := h.store.getFiscalYears(ctx, org.ID)
teams, _ := h.store.getTeams(ctx, org.ID)
members, _ := h.store.getMembers(ctx, org.ID)
var active *FiscalYear
for i := range years {
if years[i].Status == FiscalYearActive {
active = &years[i]
break
}
}
render(w, orgHomeTmpl, &OrgHomeData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name,
Route: "orgs",
Org: *org,
MyRole: me.Role,
MyTeamIDs: me.TeamIDs,
FiscalYears: years,
ActiveYear: active,
Teams: teams,
Members: members,
})
})(w, r)
}
// ── Teams ─────────────────────────────────────────────────────────────────────
func (h *Handler) OrgTeams(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
teams, _ := h.store.getTeams(ctx, org.ID)
members, _ := h.store.getMembers(ctx, org.ID)
render(w, orgTeamsTmpl, &OrgTeamsData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Teams",
Route: "orgs",
Org: *org,
MyRole: me.Role,
Teams: teams,
Members: members,
})
})(w, r)
}
func (h *Handler) OrgTeamCreate(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
name := strings.TrimSpace(r.FormValue("name"))
teamType := TeamType(r.FormValue("type"))
if teamType != TeamTypeGuest {
teamType = TeamTypeInternal
}
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
avatar := r.FormValue("avatar")
if avatar == "" {
avatar = "👥"
}
team := &OrgTeam{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
Name: name,
Type: teamType,
Avatar: avatar,
CreatedAt: time.Now(),
}
if err := h.store.createTeam(ctx, team); err != nil {
slog.Error("create team", "err", err)
http.Error(w, "could not create team", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgTeamDelete(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
teamID := r.PathValue("team_id")
if err := h.store.deleteTeam(r.Context(), teamID, org.ID); err != nil {
slog.Error("delete team", "err", err)
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/teams", http.StatusSeeOther)
})(w, r)
}
// ── Members ───────────────────────────────────────────────────────────────────
func (h *Handler) OrgMembers(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
members, _ := h.store.getMembers(ctx, org.ID)
teams, _ := h.store.getTeams(ctx, org.ID)
invites, _ := h.store.getInvites(ctx, org.ID)
render(w, orgMembersTmpl, &OrgMembersData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Members",
Route: "orgs",
Org: *org,
MyRole: me.Role,
Members: members,
Teams: teams,
Invites: invites,
})
})(w, r)
}
func (h *Handler) OrgMemberRoleUpdate(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
memberID := r.PathValue("member_id")
role := OrgRole(r.FormValue("role"))
switch role {
case OrgRoleAdmin, OrgRoleFinance, OrgRoleMember, OrgRoleViewer:
default:
http.Error(w, "invalid role", http.StatusBadRequest)
return
}
if err := h.store.updateMemberRole(ctx, memberID, org.ID, role); err != nil {
slog.Error("update member role", "err", err)
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgMemberRemove(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
memberID := r.PathValue("member_id")
// prevent removing yourself
a := getAuth(r)
if me.UserID == a.UserID && memberID == me.ID {
http.Error(w, "cannot remove yourself", http.StatusBadRequest)
return
}
if err := h.store.removeMember(r.Context(), memberID, org.ID); err != nil {
slog.Error("remove member", "err", err)
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
})(w, r)
}
// ── Invites ───────────────────────────────────────────────────────────────────
func (h *Handler) OrgInviteNew(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
teams, _ := h.store.getTeams(ctx, org.ID)
if r.Method == http.MethodGet {
render(w, orgInviteTmpl, &OrgInviteData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: "Invite to " + org.Name,
Route: "orgs",
Org: *org,
MyRole: me.Role,
Teams: teams,
})
return
}
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
role := OrgRole(r.FormValue("role"))
teamIDs := r.Form["team_ids"]
errMsg := ""
switch {
case email == "":
errMsg = "Email is required."
case role != OrgRoleAdmin && role != OrgRoleFinance && role != OrgRoleMember && role != OrgRoleViewer:
errMsg = "Invalid role."
}
if errMsg != "" {
render(w, orgInviteTmpl, &OrgInviteData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: "Invite to " + org.Name,
Route: "orgs",
Org: *org,
MyRole: me.Role,
Teams: teams,
Error: errMsg,
})
return
}
token := randomHex(32)
inv := &OrgInvite{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
Email: email,
Role: role,
TeamIDs: teamIDs,
Token: token,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
CreatedAt: time.Now(),
}
if err := h.store.createInvite(ctx, inv); err != nil {
slog.Error("create invite", "err", err)
http.Error(w, "could not create invite", http.StatusInternalServerError)
return
}
// Show the link — email delivery is Phase 5
scheme := "https"
if r.TLS == nil {
scheme = "http"
}
link := fmt.Sprintf("%s://%s/join/%s", scheme, r.Host, token)
render(w, orgInviteTmpl, &OrgInviteData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: "Invite to " + org.Name,
Route: "orgs",
Org: *org,
MyRole: me.Role,
Teams: teams,
Link: link,
})
})(w, r)
}
func (h *Handler) OrgInviteRevoke(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
inviteID := r.PathValue("invite_id")
if err := h.store.revokeInvite(r.Context(), inviteID, org.ID); err != nil {
slog.Error("revoke invite", "err", err)
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/members", http.StatusSeeOther)
})(w, r)
}
// OrgJoin handles the invite link: GET shows a confirmation page, POST accepts.
func (h *Handler) OrgJoin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
token := r.PathValue("token")
inv, err := h.store.getInviteByToken(ctx, token)
if err != nil {
http.Error(w, "invite not found or expired", http.StatusNotFound)
return
}
org, err := h.store.getOrg(ctx, inv.OrgID)
if err != nil {
http.Error(w, "organisation not found", http.StatusNotFound)
return
}
if r.Method == http.MethodGet {
render(w, orgJoinTmpl, map[string]any{
"Title": "Join " + org.Name,
"Route": "orgs",
"UserID": a.UserID,
"Email": a.Email,
"Org": org,
"Invite": inv,
})
return
}
// check already a member
if _, err := h.store.getMember(ctx, org.ID, a.UserID); err == nil {
// already in — just consume the invite and redirect
_ = h.store.consumeInvite(ctx, inv.ID)
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
return
}
member := &OrgMember{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
UserID: a.UserID,
Email: a.Email,
Role: inv.Role,
TeamIDs: inv.TeamIDs,
CreatedAt: time.Now(),
}
if err := h.store.createMember(ctx, member); err != nil {
slog.Error("create member from invite", "err", err)
http.Error(w, "could not join organisation", http.StatusInternalServerError)
return
}
if err := h.store.consumeInvite(ctx, inv.ID); err != nil {
slog.Error("consume invite", "err", err)
}
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
}
// ── Fiscal Years ──────────────────────────────────────────────────────────────
func (h *Handler) OrgFiscalYearCreate(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
label := strings.TrimSpace(r.FormValue("label"))
startStr := r.FormValue("start_date")
endStr := r.FormValue("end_date")
start, errS := time.Parse("2006-01-02", startStr)
end, errE := time.Parse("2006-01-02", endStr)
if label == "" || errS != nil || errE != nil || !end.After(start) {
http.Error(w, "invalid fiscal year data", http.StatusBadRequest)
return
}
y := &FiscalYear{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
Label: label,
Status: FiscalYearDraft,
StartDate: start,
EndDate: end,
CreatedAt: time.Now(),
}
if err := h.store.createFiscalYear(ctx, y); err != nil {
slog.Error("create fiscal year", "err", err)
http.Error(w, "could not create fiscal year", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgFiscalYearActivate(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
// verify no other year is already active
if active, _ := h.store.getActiveFiscalYear(ctx, org.ID); active != nil {
http.Error(w, "another fiscal year is already active — close it first", http.StatusConflict)
return
}
// verify all events in this year are approved
events, err := h.store.getEvents(ctx, org.ID, yearID)
if err != nil {
http.Error(w, "could not load events", http.StatusInternalServerError)
return
}
for _, e := range events {
if e.Status != EventApproved {
http.Error(w, fmt.Sprintf("event %q is not yet approved", e.Name), http.StatusConflict)
return
}
}
if err := h.store.updateFiscalYearStatus(ctx, yearID, org.ID, FiscalYearActive, bson.M{
"started_at": time.Now(),
}); err != nil {
slog.Error("activate fiscal year", "err", err)
http.Error(w, "could not activate fiscal year", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
})(w, r)
}
// ── Events ────────────────────────────────────────────────────────────────────
func (h *Handler) OrgEventList(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil {
http.Error(w, "fiscal year not found", http.StatusNotFound)
return
}
events, _ := h.store.getEvents(ctx, org.ID, yearID)
teams, _ := h.store.getTeams(ctx, org.ID)
teamMap := make(map[string]OrgTeam, len(teams))
for _, t := range teams {
teamMap[t.ID] = t
}
summaries := make([]OrgEventSummary, 0, len(events))
for _, ev := range events {
lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID)
var inc, exp int64
for _, l := range lines {
if l.Type == BudgetIncome {
inc += l.PlannedCents
} else {
exp += l.PlannedCents
}
}
evTeams := make([]OrgTeam, 0)
for _, tid := range ev.TeamIDs {
if t, ok := teamMap[tid]; ok {
evTeams = append(evTeams, t)
}
}
summaries = append(summaries, OrgEventSummary{
Event: ev,
TotalIncome: inc,
TotalExpense: exp,
Teams: evTeams,
})
}
render(w, orgEventsTmpl, &OrgEventsData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Events",
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: *year,
Events: summaries,
Teams: teams,
})
})(w, r)
}
func (h *Handler) OrgEventNew(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil {
http.Error(w, "fiscal year not found", http.StatusNotFound)
return
}
teams, _ := h.store.getTeams(ctx, org.ID)
if r.Method == http.MethodGet {
render(w, orgEventDetailTmpl, &OrgEventDetailData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: "New Event",
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: *year,
Teams: teams,
})
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
startStr := r.FormValue("date_start")
endStr := r.FormValue("date_end")
start, errS := time.Parse("2006-01-02", startStr)
end, errE := time.Parse("2006-01-02", endStr)
if errS != nil || errE != nil || end.Before(start) {
http.Error(w, "invalid dates", http.StatusBadRequest)
return
}
teamIDs := r.Form["team_ids"]
ev := &OrgEvent{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
FiscalYearID: yearID,
TeamIDs: teamIDs,
Name: name,
Description: r.FormValue("description"),
Goals: r.FormValue("goals"),
DateStart: start,
DateEnd: end,
Status: EventDraft,
CreatedBy: r.Header.Get("X-Auth-User-Id"),
CreatedAt: time.Now(),
}
if err := h.store.createEvent(ctx, ev); err != nil {
slog.Error("create event", "err", err)
http.Error(w, "could not create event", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+ev.ID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgEventDetail(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil {
http.Error(w, "fiscal year not found", http.StatusNotFound)
return
}
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
lines, _ := h.store.getBudgetLines(ctx, eventID, org.ID)
comments, _ := h.store.getEventComments(ctx, eventID, org.ID)
teams, _ := h.store.getTeams(ctx, org.ID)
teamMap := make(map[string]OrgTeam, len(teams))
for _, t := range teams {
teamMap[t.ID] = t
}
evTeams := make([]OrgTeam, 0)
for _, tid := range ev.TeamIDs {
if t, ok := teamMap[tid]; ok {
evTeams = append(evTeams, t)
}
}
var inc, exp int64
for _, l := range lines {
if l.Type == BudgetIncome {
inc += l.PlannedCents
} else {
exp += l.PlannedCents
}
}
render(w, orgEventDetailTmpl, &OrgEventDetailData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: ev.Name,
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: *year,
Event: *ev,
BudgetLines: lines,
Comments: comments,
Teams: teams,
EventTeams: evTeams,
TotalIncome: inc,
TotalExpense: exp,
})
})(w, r)
}
func (h *Handler) OrgEventEdit(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")
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
if ev.Status != EventDraft && ev.Status != EventReview {
http.Error(w, "cannot edit event in current status", http.StatusConflict)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
startStr := r.FormValue("date_start")
endStr := r.FormValue("date_end")
start, errS := time.Parse("2006-01-02", startStr)
end, errE := time.Parse("2006-01-02", endStr)
if errS != nil || errE != nil || end.Before(start) {
http.Error(w, "invalid dates", http.StatusBadRequest)
return
}
update := bson.M{
"name": name,
"description": r.FormValue("description"),
"goals": r.FormValue("goals"),
"date_start": start,
"date_end": end,
"team_ids": r.Form["team_ids"],
}
if err := h.store.updateEvent(ctx, eventID, org.ID, update); err != nil {
slog.Error("update event", "err", err)
http.Error(w, "could not update event", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgEventDelete(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")
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
if ev.Status != EventDraft {
http.Error(w, "only draft events can be deleted", http.StatusConflict)
return
}
_ = h.store.deleteEvent(ctx, eventID, org.ID)
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events", http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgEventSubmit(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")
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
if ev.Status != EventDraft {
http.Error(w, "only draft events can be submitted", http.StatusConflict)
return
}
if err := h.store.updateEvent(ctx, eventID, org.ID, bson.M{"status": EventReview}); err != nil {
slog.Error("submit event", "err", err)
http.Error(w, "could not submit event", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgEventReview(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
eventID := r.PathValue("event_id")
action := r.FormValue("action") // "approve", "reject", "comment"
body := strings.TrimSpace(r.FormValue("comment"))
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
if ev.Status != EventReview {
http.Error(w, "event is not under review", http.StatusConflict)
return
}
if body != "" {
c := &EventComment{
ID: bson.NewObjectID().Hex(),
EventID: eventID,
OrgID: org.ID,
UserID: me.ID,
UserEmail: me.Email,
Kind: CommentReview,
Body: body,
CreatedAt: time.Now(),
}
_ = h.store.createEventComment(ctx, c)
}
var newStatus EventStatus
switch action {
case "approve":
newStatus = EventApproved
case "reject":
newStatus = EventRejected
default:
// comment only — stay in review
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
return
}
if err := h.store.updateEvent(ctx, eventID, org.ID, bson.M{"status": newStatus}); err != nil {
slog.Error("review event", "err", err)
http.Error(w, "could not update event status", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
// OrgEventFeedback adds a post-mortem feedback comment (kind=feedback) after year closes.
func (h *Handler) OrgEventFeedback(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil || year.Status != FiscalYearClosed {
http.Error(w, "feedback only allowed on closed fiscal years", http.StatusConflict)
return
}
body := strings.TrimSpace(r.FormValue("comment"))
if body == "" {
http.Error(w, "comment required", http.StatusBadRequest)
return
}
c := &EventComment{
ID: bson.NewObjectID().Hex(),
EventID: eventID,
OrgID: org.ID,
UserID: me.ID,
UserEmail: me.Email,
Kind: CommentFeedback,
Body: body,
CreatedAt: time.Now(),
}
_ = h.store.createEventComment(ctx, c)
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(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) {
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")
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 modify approved event budget", http.StatusConflict)
return
}
amtStr := r.FormValue("amount")
amtFloat, err := parseEuroAmount(amtStr)
if err != nil {
http.Error(w, "invalid amount", http.StatusBadRequest)
return
}
lineType := BudgetLineType(r.FormValue("type"))
if lineType != BudgetIncome {
lineType = BudgetExpense
}
category := strings.TrimSpace(r.FormValue("category"))
if category == "" {
category = "General"
}
line := &BudgetLine{
ID: bson.NewObjectID().Hex(),
EventID: eventID,
OrgID: org.ID,
Category: category,
Type: lineType,
PlannedCents: amtFloat,
Description: strings.TrimSpace(r.FormValue("description")),
CreatedAt: time.Now(),
}
if err := h.store.createBudgetLine(ctx, line); err != nil {
slog.Error("create budget line", "err", err)
http.Error(w, "could not create budget line", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgBudgetLineDelete(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")
lineID := r.PathValue("line_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 modify approved event budget", http.StatusConflict)
return
}
_ = h.store.deleteBudgetLine(ctx, lineID, org.ID)
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
// ── Transaction Requests ──────────────────────────────────────────────────────
func (h *Handler) OrgRequestList(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
statusFilter := r.URL.Query().Get("status")
filter := bson.M{"org_id": org.ID}
if statusFilter != "" {
filter["status_log"] = bson.M{"$elemMatch": bson.M{"status": statusFilter}}
}
// Guest team members only see their team's requests
if len(me.TeamIDs) > 0 {
teams, _ := h.store.getTeams(ctx, org.ID)
guestOnly := true
for _, t := range teams {
for _, tid := range me.TeamIDs {
if t.ID == tid && t.Type == TeamTypeInternal {
guestOnly = false
}
}
}
if guestOnly {
filter["team_id"] = bson.M{"$in": me.TeamIDs}
}
}
requests, _ := h.store.getTxRequests(ctx, org.ID, filter)
events, _ := h.store.getEvents(ctx, org.ID, "")
teams, _ := h.store.getTeams(ctx, org.ID)
render(w, orgRequestsTmpl, &OrgRequestsData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Requests",
Route: "orgs",
Org: *org,
MyRole: me.Role,
Requests: requests,
Events: events,
Teams: teams,
StatusFilter: statusFilter,
})
})(w, r)
}
func (h *Handler) OrgRequestNew(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
// only in active fiscal year
activeYear, err := h.store.getActiveFiscalYear(ctx, org.ID)
if err != nil || activeYear == nil {
http.Error(w, "no active fiscal year — requests can only be submitted during an active year", http.StatusConflict)
return
}
events, _ := h.store.getEvents(ctx, org.ID, activeYear.ID)
teams, _ := h.store.getTeams(ctx, org.ID)
if r.Method == http.MethodGet {
render(w, orgRequestDetailTmpl, &OrgRequestDetailData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: "New Request",
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: activeYear,
NewEvents: events,
NewTeams: teams,
})
return
}
txType := TxRequestType(r.FormValue("type"))
switch txType {
case TxReimbursement, TxPurchaseOrder, TxCashAdvance, TxIncome, TxBudgetTransfer:
default:
http.Error(w, "invalid request type", http.StatusBadRequest)
return
}
amtCents, err := parseEuroAmount(r.FormValue("amount"))
if err != nil {
http.Error(w, "invalid amount", http.StatusBadRequest)
return
}
req := &TxRequest{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
FiscalYearID: activeYear.ID,
EventID: r.FormValue("event_id"),
BudgetLineID: r.FormValue("budget_line_id"),
TeamID: r.FormValue("team_id"),
SubmittedBy: me.ID,
SubmitterEmail: me.Email,
Type: txType,
Description: strings.TrimSpace(r.FormValue("description")),
AmountCents: amtCents,
Vendor: strings.TrimSpace(r.FormValue("vendor")),
PayerName: strings.TrimSpace(r.FormValue("payer_name")),
PaymentMethod: r.FormValue("payment_method"),
AttachmentIDs: []string{},
StatusLog: []StatusLogEntry{{
Status: TxDraft,
ChangedBy: me.ID,
ChangedAt: time.Now(),
}},
CreatedAt: time.Now(),
}
if dueDateStr := r.FormValue("due_date"); dueDateStr != "" {
if d, err := time.Parse("2006-01-02", dueDateStr); err == nil {
req.DueDate = d
}
}
if txType == TxBudgetTransfer {
req.FromBudgetLineID = r.FormValue("from_budget_line_id")
req.ToBudgetLineID = r.FormValue("to_budget_line_id")
}
if err := h.store.createTxRequest(ctx, req); err != nil {
slog.Error("create tx request", "err", err)
http.Error(w, "could not create request", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+req.ID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgRequestDetail(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
req, err := h.store.getTxRequest(ctx, reqID, org.ID)
if err != nil {
http.Error(w, "request not found", http.StatusNotFound)
return
}
var ev *OrgEvent
var bl *BudgetLine
var team *OrgTeam
var fy *FiscalYear
if req.EventID != "" {
ev, _ = h.store.getEvent(ctx, req.EventID, org.ID)
}
if req.FiscalYearID != "" {
fy, _ = h.store.getFiscalYear(ctx, req.FiscalYearID, org.ID)
}
if req.BudgetLineID != "" {
lines, _ := h.store.getBudgetLines(ctx, req.EventID, org.ID)
for i := range lines {
if lines[i].ID == req.BudgetLineID {
bl = &lines[i]
break
}
}
}
if req.TeamID != "" {
t, _ := h.store.getTeam(ctx, req.TeamID, org.ID)
team = t
}
attachments, _ := h.store.getAttachments(ctx, reqID, org.ID)
render(w, orgRequestDetailTmpl, &OrgRequestDetailData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: string(req.Type) + " Request",
Route: "orgs",
Org: *org,
MyRole: me.Role,
Request: *req,
Event: ev,
BudgetLine: bl,
Team: team,
FiscalYear: fy,
Attachments: attachments,
})
})(w, r)
}
func (h *Handler) OrgRequestAction(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
action := r.FormValue("action")
comment := strings.TrimSpace(r.FormValue("comment"))
req, err := h.store.getTxRequest(ctx, reqID, org.ID)
if err != nil {
http.Error(w, "request not found", http.StatusNotFound)
return
}
current := req.CurrentStatus()
canManage := me.Role == OrgRoleAdmin || me.Role == OrgRoleFinance
var newStatus TxRequestStatus
switch action {
case "submit":
if current != TxDraft {
http.Error(w, "only draft requests can be submitted", http.StatusConflict)
return
}
newStatus = TxSubmitted
case "request_info":
if !canManage {
http.Error(w, "insufficient permissions", http.StatusForbidden)
return
}
if comment == "" {
http.Error(w, "comment required when requesting information", http.StatusBadRequest)
return
}
newStatus = TxInfoRequested
case "review":
if !canManage {
http.Error(w, "insufficient permissions", http.StatusForbidden)
return
}
newStatus = TxUnderReview
case "approve":
if !canManage {
http.Error(w, "insufficient permissions", http.StatusForbidden)
return
}
newStatus = TxApproved
case "reject":
if !canManage {
http.Error(w, "insufficient permissions", http.StatusForbidden)
return
}
newStatus = TxRejected
case "cancel":
if current != TxDraft && current != TxSubmitted && current != TxInfoRequested {
http.Error(w, "cannot cancel in current status", http.StatusConflict)
return
}
newStatus = TxCancelled
case "mark_paid":
if !canManage || req.Type != TxReimbursement {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxPaid
case "mark_ordered":
if !canManage || req.Type != TxPurchaseOrder {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxOrdered
case "mark_delivered":
if req.Type != TxPurchaseOrder {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxDelivered
case "dispute":
if !canManage || req.Type != TxPurchaseOrder {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxDisputed
case "disburse":
if !canManage || req.Type != TxCashAdvance {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxDisbursed
case "settlement_due":
if !canManage || req.Type != TxCashAdvance {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxSettlementDue
case "mark_pending_payment":
if !canManage || req.Type != TxIncome {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxPendingPayment
case "mark_received":
if !canManage || req.Type != TxIncome {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxReceived
case "reconcile":
if !canManage {
http.Error(w, "insufficient permissions", http.StatusForbidden)
return
}
newStatus = TxReconciled
case "done":
if !canManage || req.Type != TxBudgetTransfer {
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
newStatus = TxDone
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
entry := StatusLogEntry{
Status: newStatus,
ChangedBy: me.ID,
ChangedAt: time.Now(),
Comment: comment,
}
if err := h.store.appendStatusLog(ctx, reqID, org.ID, entry); err != nil {
slog.Error("append status log", "err", err)
http.Error(w, "could not update request status", http.StatusInternalServerError)
return
}
// when approved, create a ledger entry
if newStatus == TxApproved {
ledger := &OrgLedgerEntry{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
FiscalYearID: req.FiscalYearID,
EventID: req.EventID,
BudgetLineID: req.BudgetLineID,
TeamID: req.TeamID,
RequestID: req.ID,
AmountCents: req.AmountCents,
Description: req.Description,
Date: time.Now(),
CreatedAt: time.Now(),
}
if err := h.store.createLedgerEntry(ctx, ledger); err != nil {
slog.Error("create ledger entry", "err", err)
}
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgRequestDelivery(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
req, err := h.store.getTxRequest(ctx, reqID, org.ID)
if err != nil || req.Type != TxPurchaseOrder {
http.Error(w, "purchase order not found", http.StatusNotFound)
return
}
if req.CurrentStatus() != TxOrdered {
http.Error(w, "delivery can only be recorded on ordered requests", http.StatusConflict)
return
}
amtCents, err := parseEuroAmount(r.FormValue("actual_amount"))
if err != nil {
http.Error(w, "invalid amount", http.StatusBadRequest)
return
}
delivery := &PODelivery{
ActualAmountCents: amtCents,
ActualVendor: strings.TrimSpace(r.FormValue("actual_vendor")),
DeliveredAt: time.Now(),
StoreChanged: r.FormValue("store_changed") == "true",
ChangeNote: strings.TrimSpace(r.FormValue("change_note")),
}
update := bson.M{"delivery": delivery}
if err := h.store.updateTxRequest(ctx, reqID, org.ID, update); err != nil {
slog.Error("record delivery", "err", err)
http.Error(w, "could not record delivery", http.StatusInternalServerError)
return
}
entry := StatusLogEntry{Status: TxDelivered, ChangedBy: me.ID, ChangedAt: time.Now()}
_ = h.store.appendStatusLog(ctx, reqID, org.ID, entry)
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgRequestSettle(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
req, err := h.store.getTxRequest(ctx, reqID, org.ID)
if err != nil || req.Type != TxCashAdvance {
http.Error(w, "cash advance not found", http.StatusNotFound)
return
}
cs := req.CurrentStatus()
if cs != TxDisbursed && cs != TxSettlementDue && cs != TxPartialSettlement {
http.Error(w, "cannot settle in current status", http.StatusConflict)
return
}
spentCents, err := parseEuroAmount(r.FormValue("amount_spent"))
if err != nil {
http.Error(w, "invalid spent amount", http.StatusBadRequest)
return
}
returnedCents, err := parseEuroAmount(r.FormValue("amount_returned"))
if err != nil {
returnedCents = 0
}
settlement := &CashSettlement{
AmountSpentCents: spentCents,
AmountReturnedCents: returnedCents,
SettledAt: time.Now(),
}
update := bson.M{"settlement": settlement}
if err := h.store.updateTxRequest(ctx, reqID, org.ID, update); err != nil {
slog.Error("record settlement", "err", err)
http.Error(w, "could not record settlement", http.StatusInternalServerError)
return
}
newStatus := TxSettled
if returnedCents < (req.AmountCents - spentCents) {
newStatus = TxPartialSettlement
}
entry := StatusLogEntry{Status: newStatus, ChangedBy: me.ID, ChangedAt: time.Now()}
_ = h.store.appendStatusLog(ctx, reqID, org.ID, entry)
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgRequestUpload(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
reqID := r.PathValue("req_id")
if _, err := h.store.getTxRequest(ctx, reqID, org.ID); err != nil {
http.Error(w, "request not found", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(20 << 20); err != nil {
http.Error(w, "file too large (max 20 MB)", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
attachID := bson.NewObjectID().Hex()
dir := fmt.Sprintf("/data/org-files/%s/%s", org.ID, reqID)
if err := os.MkdirAll(dir, 0o750); err != nil {
slog.Error("mkdir attachment dir", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
storagePath := fmt.Sprintf("%s/%s", dir, attachID)
dst, err := os.Create(storagePath)
if err != nil {
slog.Error("create attachment file", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer dst.Close()
size, err := io.Copy(dst, file)
if err != nil {
slog.Error("write attachment file", "err", err)
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
mime := header.Header.Get("Content-Type")
if mime == "" {
mime = "application/octet-stream"
}
attach := &OrgAttachment{
ID: attachID,
OrgID: org.ID,
RequestID: reqID,
UploadedBy: me.ID,
UploadedAt: time.Now(),
Filename: header.Filename,
MimeType: mime,
SizeBytes: size,
StoragePath: storagePath,
}
if err := h.store.createAttachment(ctx, attach); err != nil {
slog.Error("save attachment metadata", "err", err)
http.Error(w, "could not save attachment", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/requests/"+reqID, http.StatusSeeOther)
})(w, r)
}
// ── Ledger ────────────────────────────────────────────────────────────────────
func (h *Handler) OrgLedger(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.URL.Query().Get("year_id")
years, _ := h.store.getFiscalYears(ctx, org.ID)
var activeYear *FiscalYear
for i := range years {
if years[i].Status == FiscalYearActive {
activeYear = &years[i]
}
}
if yearID == "" && activeYear != nil {
yearID = activeYear.ID
}
var fy *FiscalYear
for i := range years {
if years[i].ID == yearID {
fy = &years[i]
break
}
}
entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{})
events, _ := h.store.getEvents(ctx, org.ID, yearID)
teams, _ := h.store.getTeams(ctx, org.ID)
evMap := make(map[string]OrgEvent, len(events))
for _, e := range events {
evMap[e.ID] = e
}
teamMap := make(map[string]OrgTeam, len(teams))
for _, t := range teams {
teamMap[t.ID] = t
}
var inc, exp int64
for _, e := range entries {
if e.AmountCents >= 0 {
inc += e.AmountCents
} else {
exp += -e.AmountCents
}
}
render(w, orgLedgerTmpl, &OrgLedgerData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Ledger",
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: fy,
FiscalYears: years,
Entries: entries,
Events: evMap,
Teams: teamMap,
TotalIncome: inc,
TotalExpense: exp,
})
})(w, r)
}
func (h *Handler) OrgBankImport(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin, OrgRoleFinance)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
activeYear, _ := h.store.getActiveFiscalYear(ctx, org.ID)
if r.Method == http.MethodGet {
render(w, orgBankImportTmpl, &OrgBankImportData{
UserID: r.Header.Get("X-Auth-User-Id"),
Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Bank Import",
Route: "orgs",
Org: *org,
MyRole: me.Role,
FiscalYear: activeYear,
})
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "could not parse form", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("csv")
if err != nil {
http.Error(w, "csv file required", http.StatusBadRequest)
return
}
defer file.Close()
rows, err := parseBankCSV(file)
if err != nil {
render(w, orgBankImportTmpl, &OrgBankImportData{
UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Bank Import", Route: "orgs",
Org: *org, MyRole: me.Role, FiscalYear: activeYear,
Error: "could not parse CSV: " + err.Error(),
})
return
}
if r.FormValue("confirm") != "1" {
// preview mode
render(w, orgBankImportTmpl, &OrgBankImportData{
UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Bank Import", Route: "orgs",
Org: *org, MyRole: me.Role, FiscalYear: activeYear,
Rows: rows,
})
return
}
// import confirmed
fy := activeYear
if fy == nil {
http.Error(w, "no active fiscal year", http.StatusConflict)
return
}
imported := 0
for _, row := range rows {
d, err := time.Parse("2006-01-02", row.Date)
if err != nil {
d = time.Now()
}
entry := &OrgLedgerEntry{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
FiscalYearID: fy.ID,
AmountCents: row.AmountCents,
Description: row.Description,
BankRef: row.Reference,
Date: d,
CreatedAt: time.Now(),
}
if err := h.store.createLedgerEntry(ctx, entry); err == nil {
imported++
}
}
render(w, orgBankImportTmpl, &OrgBankImportData{
UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Bank Import", Route: "orgs",
Org: *org, MyRole: me.Role, FiscalYear: fy,
Imported: imported,
})
})(w, r)
}
// ── Plan vs actual analysis ───────────────────────────────────────────────────
func (h *Handler) OrgAnalysis(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil {
http.Error(w, "fiscal year not found", http.StatusNotFound)
return
}
years, _ := h.store.getFiscalYears(ctx, org.ID)
events, _ := h.store.getEvents(ctx, org.ID, yearID)
teams, _ := h.store.getTeams(ctx, org.ID)
entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{})
// build actual maps
actualByEvent := make(map[string]int64)
actualByTeam := make(map[string]int64)
for _, e := range entries {
actualByEvent[e.EventID] += e.AmountCents
actualByTeam[e.TeamID] += e.AmountCents
}
teamMap := make(map[string]OrgTeam, len(teams))
for _, t := range teams {
teamMap[t.ID] = t
}
var totalPI, totalAI, totalPE, totalAE int64
eventRows := make([]AnalysisEventRow, 0, len(events))
for _, ev := range events {
lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID)
var pi, pe int64
for _, l := range lines {
if l.Type == BudgetIncome {
pi += l.PlannedCents
} else {
pe += l.PlannedCents
}
}
actual := actualByEvent[ev.ID]
var ai, ae int64
if actual >= 0 {
ai = actual
} else {
ae = -actual
}
totalPI += pi
totalPE += pe
totalAI += ai
totalAE += ae
eventRows = append(eventRows, AnalysisEventRow{
Event: ev, PlannedIncome: pi, ActualIncome: ai,
PlannedExpense: pe, ActualExpense: ae,
})
}
// team rows — aggregate from events by team membership
teamActual := make(map[string]struct{ pi, pe, ai, ae int64 })
for _, ev := range events {
lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID)
var pi, pe int64
for _, l := range lines {
if l.Type == BudgetIncome {
pi += l.PlannedCents
} else {
pe += l.PlannedCents
}
}
actual := actualByTeam[ev.ID]
var ai, ae int64
if actual >= 0 {
ai = actual
} else {
ae = -actual
}
for _, tid := range ev.TeamIDs {
a := teamActual[tid]
a.pi += pi / int64(max(len(ev.TeamIDs), 1))
a.pe += pe / int64(max(len(ev.TeamIDs), 1))
a.ai += ai / int64(max(len(ev.TeamIDs), 1))
a.ae += ae / int64(max(len(ev.TeamIDs), 1))
teamActual[tid] = a
}
}
teamRows := make([]AnalysisTeamRow, 0, len(teams))
for _, t := range teams {
a := teamActual[t.ID]
teamRows = append(teamRows, AnalysisTeamRow{
Team: t, PlannedIncome: a.pi, ActualIncome: a.ai,
PlannedExpense: a.pe, ActualExpense: a.ae,
})
}
render(w, orgAnalysisTmpl, &OrgAnalysisData{
UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — Analysis", Route: "orgs",
Org: *org, MyRole: me.Role, FiscalYear: *year, FiscalYears: years,
EventRows: eventRows, TeamRows: teamRows,
TotalPlannedIncome: totalPI, TotalActualIncome: totalAI,
TotalPlannedExpense: totalPE, TotalActualExpense: totalAE,
})
})(w, r)
}
// ── Year-end report ───────────────────────────────────────────────────────────
func (h *Handler) OrgReport(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")
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil {
http.Error(w, "fiscal year not found", http.StatusNotFound)
return
}
years, _ := h.store.getFiscalYears(ctx, org.ID)
events, _ := h.store.getEvents(ctx, org.ID, yearID)
teams, _ := h.store.getTeams(ctx, org.ID)
entries, _ := h.store.getLedgerEntries(ctx, org.ID, yearID, bson.M{})
teamMap := make(map[string]OrgTeam, len(teams))
for _, t := range teams {
teamMap[t.ID] = t
}
actualByEvent := make(map[string]int64)
for _, e := range entries {
actualByEvent[e.EventID] += e.AmountCents
}
var totalPI, totalAI, totalPE, totalAE int64
eventReports := make([]EventReport, 0, len(events))
for _, ev := range events {
lines, _ := h.store.getBudgetLines(ctx, ev.ID, org.ID)
comments, _ := h.store.getEventComments(ctx, ev.ID, org.ID)
var pi, pe int64
for _, l := range lines {
if l.Type == BudgetIncome {
pi += l.PlannedCents
} else {
pe += l.PlannedCents
}
}
actual := actualByEvent[ev.ID]
var ai, ae int64
if actual >= 0 {
ai = actual
} else {
ae = -actual
}
totalPI += pi
totalPE += pe
totalAI += ai
totalAE += ae
feedbackComments := make([]EventComment, 0)
for _, c := range comments {
if c.Kind == CommentFeedback {
feedbackComments = append(feedbackComments, c)
}
}
evTeams := make([]OrgTeam, 0)
for _, tid := range ev.TeamIDs {
if t, ok := teamMap[tid]; ok {
evTeams = append(evTeams, t)
}
}
eventReports = append(eventReports, EventReport{
Event: ev, BudgetLines: lines, Comments: feedbackComments,
PlannedIncome: pi, ActualIncome: ai,
PlannedExpense: pe, ActualExpense: ae,
Teams: evTeams,
})
}
render(w, orgReportTmpl, &OrgReportData{
UserID: r.Header.Get("X-Auth-User-Id"), Email: r.Header.Get("X-Auth-Email"),
Title: org.Name + " — " + year.Label + " Report", Route: "orgs",
Org: *org, MyRole: me.Role, FiscalYear: *year, FiscalYears: years,
EventReports: eventReports,
TotalPlannedIncome: totalPI, TotalActualIncome: totalAI,
TotalPlannedExpense: totalPE, TotalActualExpense: totalAE,
})
})(w, r)
}
// ── Fiscal year close ─────────────────────────────────────────────────────────
func (h *Handler) OrgFiscalYearClose(w http.ResponseWriter, r *http.Request) {
h.requireOrgRole(OrgRoleAdmin)(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
if err := h.store.updateFiscalYearStatus(ctx, yearID, org.ID, FiscalYearClosed, bson.M{
"closed_at": time.Now(),
}); err != nil {
slog.Error("close fiscal year", "err", err)
http.Error(w, "could not close fiscal year", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug, http.StatusSeeOther)
})(w, r)
}
// ── Route registration ────────────────────────────────────────────────────────
func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
// Exact/literal patterns first (must precede wildcard {slug} routes)
mux.HandleFunc("GET /orgs", h.authMW(h.OrgList))
mux.HandleFunc("GET /orgs/new", h.authMW(h.OrgCreate))
mux.HandleFunc("POST /orgs/new", h.authMW(h.OrgCreate))
mux.HandleFunc("GET /join/{token}", h.authMW(h.OrgJoin))
mux.HandleFunc("POST /join/{token}", h.authMW(h.OrgJoin))
// {slug} wildcard routes
mux.HandleFunc("GET /orgs/{slug}", h.OrgHome)
// Teams
mux.HandleFunc("GET /orgs/{slug}/teams", h.OrgTeams)
mux.HandleFunc("POST /orgs/{slug}/teams", h.OrgTeamCreate)
mux.HandleFunc("POST /orgs/{slug}/teams/{team_id}/delete", h.OrgTeamDelete)
// Members
mux.HandleFunc("GET /orgs/{slug}/members", h.OrgMembers)
mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/role", h.OrgMemberRoleUpdate)
mux.HandleFunc("POST /orgs/{slug}/members/{member_id}/remove", h.OrgMemberRemove)
// Invites
mux.HandleFunc("GET /orgs/{slug}/invite", h.OrgInviteNew)
mux.HandleFunc("POST /orgs/{slug}/invite", h.OrgInviteNew)
mux.HandleFunc("POST /orgs/{slug}/invites/{invite_id}/revoke", h.OrgInviteRevoke)
// Fiscal years
mux.HandleFunc("POST /orgs/{slug}/years", h.OrgFiscalYearCreate)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/activate", h.OrgFiscalYearActivate)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/close", h.OrgFiscalYearClose)
// Events (literal "new" before {event_id} wildcard)
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events", h.OrgEventList)
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events/new", h.OrgEventNew)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/new", h.OrgEventNew)
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/events/{event_id}", h.OrgEventDetail)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/edit", h.OrgEventEdit)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/delete", h.OrgEventDelete)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/submit", h.OrgEventSubmit)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/review", h.OrgEventReview)
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)
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/report", h.OrgReport)
// Transaction requests (literal "new" before {req_id} wildcard)
mux.HandleFunc("GET /orgs/{slug}/requests", h.OrgRequestList)
mux.HandleFunc("GET /orgs/{slug}/requests/new", h.OrgRequestNew)
mux.HandleFunc("POST /orgs/{slug}/requests/new", h.OrgRequestNew)
mux.HandleFunc("GET /orgs/{slug}/requests/{req_id}", h.OrgRequestDetail)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/action", h.OrgRequestAction)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/delivery", h.OrgRequestDelivery)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/settle", h.OrgRequestSettle)
mux.HandleFunc("POST /orgs/{slug}/requests/{req_id}/upload", h.OrgRequestUpload)
// Ledger (literal "import" before potential wildcards)
mux.HandleFunc("GET /orgs/{slug}/ledger", h.OrgLedger)
mux.HandleFunc("GET /orgs/{slug}/ledger/import", h.OrgBankImport)
mux.HandleFunc("POST /orgs/{slug}/ledger/import", h.OrgBankImport)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// parseEuroAmount converts a user-entered decimal string (e.g. "12.50") to cents.
func parseEuroAmount(s string) (int64, error) {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, ",", ".")
var euros, cents int64
parts := strings.SplitN(s, ".", 2)
var err error
if euros, err = parseInt64(parts[0]); err != nil {
return 0, fmt.Errorf("invalid amount")
}
if len(parts) == 2 {
centStr := parts[1]
if len(centStr) == 1 {
centStr += "0"
} else if len(centStr) > 2 {
centStr = centStr[:2]
}
if cents, err = parseInt64(centStr); err != nil {
return 0, fmt.Errorf("invalid amount")
}
}
return euros*100 + cents, nil
}
func parseInt64(s string) (int64, error) {
if s == "" {
return 0, nil
}
var n int64
neg := false
if s[0] == '-' {
neg = true
s = s[1:]
}
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("not a number")
}
n = n*10 + int64(c-'0')
}
if neg {
n = -n
}
return n, nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func csvReader(r io.Reader) ([][]string, error) {
cr := csv.NewReader(r)
cr.TrimLeadingSpace = true
return cr.ReadAll()
}
// parseBankCSV reads a bank statement CSV. It expects at minimum columns:
// date, description, amount (and optionally reference).
// Negative amounts are expenses, positive are income.
func parseBankCSV(r io.Reader) ([]BankImportRow, error) {
records, err := csvReader(r)
if err != nil {
return nil, err
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV is empty")
}
header := records[0]
idx := func(name string) int {
for i, h := range header {
if strings.EqualFold(strings.TrimSpace(h), name) {
return i
}
}
return -1
}
dateIdx := idx("date")
descIdx := idx("description")
amtIdx := idx("amount")
refIdx := idx("reference")
if dateIdx < 0 || descIdx < 0 || amtIdx < 0 {
return nil, fmt.Errorf("CSV must have columns: date, description, amount")
}
rows := make([]BankImportRow, 0, len(records)-1)
for _, rec := range records[1:] {
if len(rec) <= amtIdx {
continue
}
amt, err := parseEuroAmount(rec[amtIdx])
if err != nil {
continue
}
ref := ""
if refIdx >= 0 && refIdx < len(rec) {
ref = strings.TrimSpace(rec[refIdx])
}
rows = append(rows, BankImportRow{
Date: strings.TrimSpace(rec[dateIdx]),
Description: strings.TrimSpace(rec[descIdx]),
AmountCents: amt,
Reference: ref,
})
}
return rows, nil
}