feat(finance): org management scaffold — Phase 1

Adds multi-tenant organisation support inside the existing finance namespace.
Users can create organisations, invite others via a copy-paste token link,
and manage teams/members with RBAC (admin, finance, member, viewer).
Fiscal year lifecycle is gated: activation requires all planned events to
be approved first. All org data lives in `org_`-prefixed MongoDB collections.

New files:
- models_org.go      — domain types (Org, OrgTeam, OrgMember, OrgInvite,
                       FiscalYear, OrgEvent, BudgetLine, EventComment,
                       TxRequest with full StatusLog audit trail, etc.)
- store_org.go       — MongoDB store methods for all org collections
- handler_org.go     — HTTP handlers + RegisterOrgRoutes(); join invite
                       route lives at /join/{token} to avoid ServeMux
                       conflict with /orgs/{slug}/... wildcard routes
- templates/org_*.html — list, create, home, teams, members, invite, join

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-14 12:43:48 +01:00
parent 1b9284801c
commit 6ed848a001
13 changed files with 2082 additions and 0 deletions

View File

@ -127,6 +127,15 @@ var (
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html") autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
peopleTmpl = parseTmpl("templates/base.html", "templates/people.html") peopleTmpl = parseTmpl("templates/base.html", "templates/people.html")
settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html") settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html")
// Org
orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html")
orgCreateTmpl = parseTmpl("templates/base.html", "templates/org_create.html")
orgHomeTmpl = parseTmpl("templates/base.html", "templates/org_home.html")
orgTeamsTmpl = parseTmpl("templates/base.html", "templates/org_teams.html")
orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html")
orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html")
orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html")
) )
type authInfo struct { type authInfo struct {
@ -194,6 +203,47 @@ type storeIface interface {
getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error) getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error)
createImportSchedule(ctx context.Context, sched *ImportSchedule) error createImportSchedule(ctx context.Context, sched *ImportSchedule) error
deleteImportSchedule(ctx context.Context, id, userID string) error deleteImportSchedule(ctx context.Context, id, userID string) error
// Org
getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error)
getOrg(ctx context.Context, orgID string) (*Org, error)
getOrgBySlug(ctx context.Context, slug string) (*Org, error)
createOrg(ctx context.Context, o *Org) error
slugExists(ctx context.Context, slug string) (bool, error)
getTeams(ctx context.Context, orgID string) ([]OrgTeam, error)
getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error)
createTeam(ctx context.Context, t *OrgTeam) error
deleteTeam(ctx context.Context, teamID, orgID string) error
getMembers(ctx context.Context, orgID string) ([]OrgMember, error)
getMember(ctx context.Context, orgID, userID string) (*OrgMember, error)
createMember(ctx context.Context, m *OrgMember) error
updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error
removeMember(ctx context.Context, memberID, orgID string) error
getInvites(ctx context.Context, orgID string) ([]OrgInvite, error)
getInviteByToken(ctx context.Context, token string) (*OrgInvite, error)
createInvite(ctx context.Context, inv *OrgInvite) error
consumeInvite(ctx context.Context, inviteID string) error
revokeInvite(ctx context.Context, inviteID, orgID string) error
getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error)
getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error)
getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error)
createFiscalYear(ctx context.Context, y *FiscalYear) error
updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error
getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error)
getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error)
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
getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error)
createBudgetLine(ctx context.Context, l *BudgetLine) error
deleteBudgetLine(ctx context.Context, lineID, orgID string) error
getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error)
createEventComment(ctx context.Context, c *EventComment) error
getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error)
getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error)
createTxRequest(ctx context.Context, r *TxRequest) error
appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error
updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error
} }
type Handler struct { type Handler struct {
@ -2488,6 +2538,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /tax", h.Tax) mux.HandleFunc("GET /tax", h.Tax)
mux.HandleFunc("GET /tax/export.csv", h.TaxExport) mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
mux.HandleFunc("GET /auto-import", h.AutoImport) mux.HandleFunc("GET /auto-import", h.AutoImport)
h.RegisterOrgRoutes(mux)
} }
func sortStrings(s []string) { func sortStrings(s []string) {

View File

@ -0,0 +1,570 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"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
}
team := &OrgTeam{
ID: bson.NewObjectID().Hex(),
OrgID: org.ID,
Name: name,
Type: teamType,
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)
}
// ── 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)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@ -170,6 +170,54 @@ func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSch
func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil } func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil }
func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil } func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil }
// ── Org stubs (not exercised in unit tests) ───────────────────────────────────
func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) {
return nil, nil
}
func (m *mockStore) getOrg(_ context.Context, _ string) (*Org, error) { return nil, nil }
func (m *mockStore) getOrgBySlug(_ context.Context, _ string) (*Org, error) { return nil, nil }
func (m *mockStore) createOrg(_ context.Context, _ *Org) error { return nil }
func (m *mockStore) slugExists(_ context.Context, _ string) (bool, error) { return false, nil }
func (m *mockStore) getTeams(_ context.Context, _ string) ([]OrgTeam, error) { return nil, nil }
func (m *mockStore) getTeam(_ context.Context, _, _ string) (*OrgTeam, error) { return nil, nil }
func (m *mockStore) createTeam(_ context.Context, _ *OrgTeam) error { return nil }
func (m *mockStore) deleteTeam(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getMembers(_ context.Context, _ string) ([]OrgMember, error) { return nil, nil }
func (m *mockStore) getMember(_ context.Context, _, _ string) (*OrgMember, error) {
return nil, nil
}
func (m *mockStore) createMember(_ context.Context, _ *OrgMember) error { return nil }
func (m *mockStore) updateMemberRole(_ context.Context, _, _ string, _ OrgRole) error { return nil }
func (m *mockStore) removeMember(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getInvites(_ context.Context, _ string) ([]OrgInvite, error) { return nil, nil }
func (m *mockStore) getInviteByToken(_ context.Context, _ string) (*OrgInvite, error) { return nil, nil }
func (m *mockStore) createInvite(_ context.Context, _ *OrgInvite) error { return nil }
func (m *mockStore) consumeInvite(_ context.Context, _ string) error { return nil }
func (m *mockStore) revokeInvite(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { return nil, nil }
func (m *mockStore) getFiscalYear(_ context.Context, _, _ string) (*FiscalYear, error) { return nil, nil }
func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { return nil, nil }
func (m *mockStore) createFiscalYear(_ context.Context, _ *FiscalYear) error { return nil }
func (m *mockStore) updateFiscalYearStatus(_ context.Context, _, _ string, _ FiscalYearStatus, _ bson.M) error {
return nil
}
func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { return nil, nil }
func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error) { return nil, nil }
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) 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 }
func (m *mockStore) getEventComments(_ context.Context, _, _ string) ([]EventComment, error) { return nil, nil }
func (m *mockStore) createEventComment(_ context.Context, _ *EventComment) error { return nil }
func (m *mockStore) getTxRequests(_ context.Context, _ string, _ bson.M) ([]TxRequest, error) { return nil, nil }
func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, error) { return nil, nil }
func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil }
func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil }
func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil }
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func newHandler(store *mockStore) *Handler { func newHandler(store *mockStore) *Handler {

View File

@ -0,0 +1,356 @@
package main
import "time"
// ── Org RBAC ─────────────────────────────────────────────────────────────────
type OrgRole string
const (
OrgRoleAdmin OrgRole = "admin"
OrgRoleFinance OrgRole = "finance"
OrgRoleMember OrgRole = "member"
OrgRoleViewer OrgRole = "viewer"
)
type TeamType string
const (
TeamTypeInternal TeamType = "internal"
TeamTypeGuest TeamType = "guest"
)
// ── Core entities ─────────────────────────────────────────────────────────────
type Org struct {
ID string `bson:"_id" json:"id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
OwnerUserID string `bson:"owner_user_id" json:"owner_user_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type OrgTeam struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
Name string `bson:"name" json:"name"`
Type TeamType `bson:"type" json:"type"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// OrgMember links a user to an org with a role and optional team subset.
// All financial approvals go through org-level finance/admin regardless of team.
// Guest team members are scoped to their own team's data only (visibility scope,
// not approval scope).
type OrgMember struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
UserID string `bson:"user_id" json:"user_id"`
Email string `bson:"email" json:"email"`
Role OrgRole `bson:"role" json:"role"`
TeamIDs []string `bson:"team_ids" json:"team_ids"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// OrgInvite is a pending invitation. Token is a random hex string.
// Email delivery is a TODO (Phase 5) — for now the link is displayed to the inviter.
type OrgInvite struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
Email string `bson:"email" json:"email"`
Role OrgRole `bson:"role" json:"role"`
TeamIDs []string `bson:"team_ids" json:"team_ids"`
Token string `bson:"token" json:"token"`
ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UsedAt time.Time `bson:"used_at,omitempty" json:"used_at,omitempty"`
}
// ── Fiscal year ───────────────────────────────────────────────────────────────
type FiscalYearStatus string
const (
FiscalYearDraft FiscalYearStatus = "draft"
FiscalYearActive FiscalYearStatus = "active"
FiscalYearClosed FiscalYearStatus = "closed"
)
type FiscalYear struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
Label string `bson:"label" json:"label"` // e.g. "2025"
Status FiscalYearStatus `bson:"status" json:"status"`
StartDate time.Time `bson:"start_date" json:"start_date"`
EndDate time.Time `bson:"end_date" json:"end_date"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
ClosedAt time.Time `bson:"closed_at,omitempty" json:"closed_at,omitempty"`
}
// ── Planning: Events & Budget ─────────────────────────────────────────────────
type EventStatus string
const (
EventDraft EventStatus = "draft"
EventReview EventStatus = "review"
EventApproved EventStatus = "approved"
EventRejected EventStatus = "rejected"
)
type OrgEvent struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
TeamIDs []string `bson:"team_ids" json:"team_ids"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Goals string `bson:"goals" json:"goals"`
DateStart time.Time `bson:"date_start" json:"date_start"`
DateEnd time.Time `bson:"date_end" json:"date_end"`
Status EventStatus `bson:"status" json:"status"`
CreatedBy string `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type BudgetLineType string
const (
BudgetIncome BudgetLineType = "income"
BudgetExpense BudgetLineType = "expense"
)
type BudgetLine struct {
ID string `bson:"_id" json:"id"`
EventID string `bson:"event_id" json:"event_id"`
OrgID string `bson:"org_id" json:"org_id"`
Category string `bson:"category" json:"category"`
Type BudgetLineType `bson:"type" json:"type"`
PlannedCents int64 `bson:"planned_cents" json:"planned_cents"`
Description string `bson:"description" json:"description"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// EventComment is used for two purposes distinguished by Kind:
// - "review" — admin → team during planning (request changes / feedback)
// - "feedback" — team post-mortem after year closes (included in year-end report)
type EventCommentKind string
const (
CommentReview EventCommentKind = "review"
CommentFeedback EventCommentKind = "feedback"
)
type EventComment struct {
ID string `bson:"_id" json:"id"`
EventID string `bson:"event_id" json:"event_id"`
OrgID string `bson:"org_id" json:"org_id"`
UserID string `bson:"user_id" json:"user_id"`
UserEmail string `bson:"user_email" json:"user_email"`
Kind EventCommentKind `bson:"kind" json:"kind"`
Body string `bson:"body" json:"body"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// ── Execution: Transaction Requests ──────────────────────────────────────────
type TxRequestType string
const (
TxReimbursement TxRequestType = "reimbursement"
TxPurchaseOrder TxRequestType = "purchase_order"
TxCashAdvance TxRequestType = "cash_advance"
TxIncome TxRequestType = "income"
TxBudgetTransfer TxRequestType = "budget_transfer"
)
type TxRequestStatus string
const (
TxDraft TxRequestStatus = "draft"
TxSubmitted TxRequestStatus = "submitted"
TxInfoRequested TxRequestStatus = "info_requested"
TxUnderReview TxRequestStatus = "under_review"
TxApproved TxRequestStatus = "approved"
TxRejected TxRequestStatus = "rejected"
TxCancelled TxRequestStatus = "cancelled"
// Reimbursement
TxPaid TxRequestStatus = "paid"
// Purchase Order
TxOrdered TxRequestStatus = "ordered"
TxDelivered TxRequestStatus = "delivered"
TxDisputed TxRequestStatus = "disputed"
// Cash Advance
TxDisbursed TxRequestStatus = "disbursed"
TxSettlementDue TxRequestStatus = "settlement_due"
TxSettled TxRequestStatus = "settled"
TxPartialSettlement TxRequestStatus = "partial_settlement"
// Income
TxPendingPayment TxRequestStatus = "pending_payment"
TxReceived TxRequestStatus = "received"
// Shared terminal
TxReconciled TxRequestStatus = "reconciled"
// Budget transfer terminal
TxDone TxRequestStatus = "done"
)
// StatusLogEntry is appended on every status change. Never mutated.
// When status = info_requested, Comment is required.
type StatusLogEntry struct {
Status TxRequestStatus `bson:"status" json:"status"`
ChangedBy string `bson:"changed_by" json:"changed_by"`
ChangedAt time.Time `bson:"changed_at" json:"changed_at"`
Comment string `bson:"comment,omitempty" json:"comment,omitempty"`
}
// PODelivery is filled in by the requester when a Purchase Order arrives.
type PODelivery struct {
ActualAmountCents int64 `bson:"actual_amount_cents" json:"actual_amount_cents"`
ActualVendor string `bson:"actual_vendor" json:"actual_vendor"`
DeliveredAt time.Time `bson:"delivered_at" json:"delivered_at"`
InvoiceAttachmentID []string `bson:"invoice_attachment_ids" json:"invoice_attachment_ids"`
StoreChanged bool `bson:"store_changed" json:"store_changed"`
ChangeNote string `bson:"change_note,omitempty" json:"change_note,omitempty"`
}
// CashSettlement is filled in by the requester when settling a Cash Advance.
type CashSettlement struct {
AmountSpentCents int64 `bson:"amount_spent_cents" json:"amount_spent_cents"`
AmountReturnedCents int64 `bson:"amount_returned_cents" json:"amount_returned_cents"`
ReceiptAttachmentIDs []string `bson:"receipt_attachment_ids" json:"receipt_attachment_ids"`
SettledAt time.Time `bson:"settled_at" json:"settled_at"`
}
type TxRequest struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
EventID string `bson:"event_id" json:"event_id"`
BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"`
TeamID string `bson:"team_id" json:"team_id"`
SubmittedBy string `bson:"submitted_by" json:"submitted_by"`
SubmitterEmail string `bson:"submitter_email" json:"submitter_email"`
Type TxRequestType `bson:"type" json:"type"`
Description string `bson:"description" json:"description"`
AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
Vendor string `bson:"vendor,omitempty" json:"vendor,omitempty"`
PayerName string `bson:"payer_name,omitempty" json:"payer_name,omitempty"`
DueDate time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
PaymentMethod string `bson:"payment_method,omitempty" json:"payment_method,omitempty"`
// PO-specific
Delivery *PODelivery `bson:"delivery,omitempty" json:"delivery,omitempty"`
// Cash advance specific
Settlement *CashSettlement `bson:"settlement,omitempty" json:"settlement,omitempty"`
// Budget transfer specific
FromBudgetLineID string `bson:"from_budget_line_id,omitempty" json:"from_budget_line_id,omitempty"`
ToBudgetLineID string `bson:"to_budget_line_id,omitempty" json:"to_budget_line_id,omitempty"`
AttachmentIDs []string `bson:"attachment_ids" json:"attachment_ids"`
StatusLog []StatusLogEntry `bson:"status_log" json:"status_log"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// CurrentStatus returns the latest status from the log.
func (r *TxRequest) CurrentStatus() TxRequestStatus {
if len(r.StatusLog) == 0 {
return TxDraft
}
return r.StatusLog[len(r.StatusLog)-1].Status
}
// OrgAttachment stores file metadata; the file lives on disk.
type OrgAttachment struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
RequestID string `bson:"request_id" json:"request_id"`
UploadedBy string `bson:"uploaded_by" json:"uploaded_by"`
UploadedAt time.Time `bson:"uploaded_at" json:"uploaded_at"`
Filename string `bson:"filename" json:"filename"`
MimeType string `bson:"mime_type" json:"mime_type"`
SizeBytes int64 `bson:"size_bytes" json:"size_bytes"`
// Path on disk: /data/org-files/{org_id}/{request_id}/{id}
StoragePath string `bson:"storage_path" json:"storage_path"`
}
// OrgLedgerEntry is created from an approved TxRequest.
// bank_ref is set when the entry is matched to a bank CSV row (reconciliation).
type OrgLedgerEntry struct {
ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"`
FiscalYearID string `bson:"fiscal_year_id" json:"fiscal_year_id"`
EventID string `bson:"event_id" json:"event_id"`
BudgetLineID string `bson:"budget_line_id" json:"budget_line_id"`
TeamID string `bson:"team_id" json:"team_id"`
RequestID string `bson:"request_id,omitempty" json:"request_id,omitempty"`
AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
Description string `bson:"description" json:"description"`
Date time.Time `bson:"date" json:"date"`
BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"`
Reconciled bool `bson:"reconciled" json:"reconciled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// ── Page data structs ─────────────────────────────────────────────────────────
type OrgListData struct {
UserID string
Email string
Title string
Route string
Orgs []OrgWithRole
}
type OrgWithRole struct {
Org Org
Role OrgRole
}
type OrgHomeData struct {
UserID string
Email string
Title string
Route string
Org Org
MyRole OrgRole
MyTeamIDs []string
FiscalYears []FiscalYear
ActiveYear *FiscalYear
Teams []OrgTeam
Members []OrgMember
}
type OrgTeamsData struct {
UserID string
Email string
Title string
Route string
Org Org
MyRole OrgRole
Teams []OrgTeam
Members []OrgMember // for showing team membership counts
}
type OrgMembersData struct {
UserID string
Email string
Title string
Route string
Org Org
MyRole OrgRole
Members []OrgMember
Teams []OrgTeam
Invites []OrgInvite
}
type OrgInviteData struct {
UserID string
Email string
Title string
Route string
Org Org
MyRole OrgRole
Teams []OrgTeam
Error string
Link string // generated invite link shown after creation
}

View File

@ -0,0 +1,529 @@
package main
import (
"context"
"fmt"
"time"
"homelab/pkg/mongo"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
mgmongo "go.mongodb.org/mongo-driver/v2/mongo"
)
// ── Collection helpers ────────────────────────────────────────────────────────
func (s *Store) orgs() *mgmongo.Collection {
return s.db.Collection("org_organizations")
}
func (s *Store) orgTeams() *mgmongo.Collection {
return s.db.Collection("org_teams")
}
func (s *Store) orgMembers() *mgmongo.Collection {
return s.db.Collection("org_members")
}
func (s *Store) orgInvites() *mgmongo.Collection {
return s.db.Collection("org_invites")
}
func (s *Store) fiscalYears() *mgmongo.Collection {
return s.db.Collection("org_fiscal_years")
}
func (s *Store) orgEvents() *mgmongo.Collection {
return s.db.Collection("org_events")
}
func (s *Store) budgetLines() *mgmongo.Collection {
return s.db.Collection("org_budget_lines")
}
func (s *Store) eventComments() *mgmongo.Collection {
return s.db.Collection("org_event_comments")
}
func (s *Store) txRequests() *mgmongo.Collection {
return s.db.Collection("org_tx_requests")
}
func (s *Store) orgLedger() *mgmongo.Collection {
return s.db.Collection("org_ledger")
}
func (s *Store) orgAttachments() *mgmongo.Collection {
return s.db.Collection("org_attachments")
}
// ── Organizations ─────────────────────────────────────────────────────────────
func (s *Store) getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getOrgsForUser")
defer span.End()
cur, err := s.orgMembers().Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, fmt.Errorf("find memberships: %w", err)
}
defer cur.Close(ctx)
var members []OrgMember
if err := cur.All(ctx, &members); err != nil {
return nil, fmt.Errorf("decode memberships: %w", err)
}
if len(members) == 0 {
return nil, nil
}
orgIDs := make([]string, len(members))
roleByOrg := make(map[string]OrgRole, len(members))
for i, m := range members {
orgIDs[i] = m.OrgID
roleByOrg[m.OrgID] = m.Role
}
cur2, err := s.orgs().Find(ctx, bson.M{"_id": bson.M{"$in": orgIDs}})
if err != nil {
return nil, fmt.Errorf("find orgs: %w", err)
}
defer cur2.Close(ctx)
var orgs []Org
if err := cur2.All(ctx, &orgs); err != nil {
return nil, fmt.Errorf("decode orgs: %w", err)
}
result := make([]OrgWithRole, len(orgs))
for i, o := range orgs {
result[i] = OrgWithRole{Org: o, Role: roleByOrg[o.ID]}
}
return result, nil
}
func (s *Store) getOrg(ctx context.Context, orgID string) (*Org, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getOrg")
defer span.End()
var o Org
if err := s.orgs().FindOne(ctx, bson.M{"_id": orgID}).Decode(&o); err != nil {
return nil, fmt.Errorf("find org: %w", err)
}
return &o, nil
}
func (s *Store) getOrgBySlug(ctx context.Context, slug string) (*Org, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getOrgBySlug")
defer span.End()
var o Org
if err := s.orgs().FindOne(ctx, bson.M{"slug": slug}).Decode(&o); err != nil {
return nil, fmt.Errorf("find org by slug: %w", err)
}
return &o, nil
}
func (s *Store) createOrg(ctx context.Context, o *Org) error {
ctx, span := mongo.StartSpan(ctx, "Store.createOrg")
defer span.End()
_, err := s.orgs().InsertOne(ctx, o)
return err
}
func (s *Store) slugExists(ctx context.Context, slug string) (bool, error) {
ctx, span := mongo.StartSpan(ctx, "Store.slugExists")
defer span.End()
n, err := s.orgs().CountDocuments(ctx, bson.M{"slug": slug})
return n > 0, err
}
// ── Teams ─────────────────────────────────────────────────────────────────────
func (s *Store) getTeams(ctx context.Context, orgID string) ([]OrgTeam, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTeams")
defer span.End()
opts := options.Find().SetSort(bson.M{"name": 1})
cur, err := s.orgTeams().Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, fmt.Errorf("find teams: %w", err)
}
defer cur.Close(ctx)
var teams []OrgTeam
if err := cur.All(ctx, &teams); err != nil {
return nil, fmt.Errorf("decode teams: %w", err)
}
return teams, nil
}
func (s *Store) getTeam(ctx context.Context, teamID, orgID string) (*OrgTeam, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTeam")
defer span.End()
var t OrgTeam
if err := s.orgTeams().FindOne(ctx, bson.M{"_id": teamID, "org_id": orgID}).Decode(&t); err != nil {
return nil, fmt.Errorf("find team: %w", err)
}
return &t, nil
}
func (s *Store) createTeam(ctx context.Context, t *OrgTeam) error {
ctx, span := mongo.StartSpan(ctx, "Store.createTeam")
defer span.End()
_, err := s.orgTeams().InsertOne(ctx, t)
return err
}
func (s *Store) deleteTeam(ctx context.Context, teamID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteTeam")
defer span.End()
_, err := s.orgTeams().DeleteOne(ctx, bson.M{"_id": teamID, "org_id": orgID})
return err
}
// ── Members ───────────────────────────────────────────────────────────────────
func (s *Store) getMembers(ctx context.Context, orgID string) ([]OrgMember, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getMembers")
defer span.End()
opts := options.Find().SetSort(bson.M{"email": 1})
cur, err := s.orgMembers().Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, fmt.Errorf("find members: %w", err)
}
defer cur.Close(ctx)
var members []OrgMember
if err := cur.All(ctx, &members); err != nil {
return nil, fmt.Errorf("decode members: %w", err)
}
return members, nil
}
func (s *Store) getMember(ctx context.Context, orgID, userID string) (*OrgMember, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getMember")
defer span.End()
var m OrgMember
if err := s.orgMembers().FindOne(ctx, bson.M{"org_id": orgID, "user_id": userID}).Decode(&m); err != nil {
return nil, fmt.Errorf("find member: %w", err)
}
return &m, nil
}
func (s *Store) createMember(ctx context.Context, m *OrgMember) error {
ctx, span := mongo.StartSpan(ctx, "Store.createMember")
defer span.End()
_, err := s.orgMembers().InsertOne(ctx, m)
return err
}
func (s *Store) updateMemberRole(ctx context.Context, memberID, orgID string, role OrgRole) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateMemberRole")
defer span.End()
_, err := s.orgMembers().UpdateOne(ctx,
bson.M{"_id": memberID, "org_id": orgID},
bson.M{"$set": bson.M{"role": role}},
)
return err
}
func (s *Store) removeMember(ctx context.Context, memberID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.removeMember")
defer span.End()
_, err := s.orgMembers().DeleteOne(ctx, bson.M{"_id": memberID, "org_id": orgID})
return err
}
// ── Invites ───────────────────────────────────────────────────────────────────
func (s *Store) getInvites(ctx context.Context, orgID string) ([]OrgInvite, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getInvites")
defer span.End()
// only pending (not yet used, not expired)
cur, err := s.orgInvites().Find(ctx, bson.M{
"org_id": orgID,
"expires_at": bson.M{"$gt": time.Now()},
"used_at": bson.M{"$exists": false},
})
if err != nil {
return nil, fmt.Errorf("find invites: %w", err)
}
defer cur.Close(ctx)
var invites []OrgInvite
if err := cur.All(ctx, &invites); err != nil {
return nil, fmt.Errorf("decode invites: %w", err)
}
return invites, nil
}
func (s *Store) getInviteByToken(ctx context.Context, token string) (*OrgInvite, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getInviteByToken")
defer span.End()
var inv OrgInvite
err := s.orgInvites().FindOne(ctx, bson.M{
"token": token,
"expires_at": bson.M{"$gt": time.Now()},
"used_at": bson.M{"$exists": false},
}).Decode(&inv)
if err != nil {
return nil, fmt.Errorf("find invite: %w", err)
}
return &inv, nil
}
func (s *Store) createInvite(ctx context.Context, inv *OrgInvite) error {
ctx, span := mongo.StartSpan(ctx, "Store.createInvite")
defer span.End()
_, err := s.orgInvites().InsertOne(ctx, inv)
return err
}
func (s *Store) consumeInvite(ctx context.Context, inviteID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.consumeInvite")
defer span.End()
_, err := s.orgInvites().UpdateOne(ctx,
bson.M{"_id": inviteID},
bson.M{"$set": bson.M{"used_at": time.Now()}},
)
return err
}
func (s *Store) revokeInvite(ctx context.Context, inviteID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.revokeInvite")
defer span.End()
// set expiry to past
_, err := s.orgInvites().UpdateOne(ctx,
bson.M{"_id": inviteID, "org_id": orgID},
bson.M{"$set": bson.M{"expires_at": time.Now().Add(-time.Second)}},
)
return err
}
// ── Fiscal Years ──────────────────────────────────────────────────────────────
func (s *Store) getFiscalYears(ctx context.Context, orgID string) ([]FiscalYear, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYears")
defer span.End()
opts := options.Find().SetSort(bson.M{"start_date": -1})
cur, err := s.fiscalYears().Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, fmt.Errorf("find fiscal years: %w", err)
}
defer cur.Close(ctx)
var years []FiscalYear
if err := cur.All(ctx, &years); err != nil {
return nil, fmt.Errorf("decode fiscal years: %w", err)
}
return years, nil
}
func (s *Store) getFiscalYear(ctx context.Context, yearID, orgID string) (*FiscalYear, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getFiscalYear")
defer span.End()
var y FiscalYear
if err := s.fiscalYears().FindOne(ctx, bson.M{"_id": yearID, "org_id": orgID}).Decode(&y); err != nil {
return nil, fmt.Errorf("find fiscal year: %w", err)
}
return &y, nil
}
func (s *Store) getActiveFiscalYear(ctx context.Context, orgID string) (*FiscalYear, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getActiveFiscalYear")
defer span.End()
var y FiscalYear
err := s.fiscalYears().FindOne(ctx, bson.M{"org_id": orgID, "status": FiscalYearActive}).Decode(&y)
if err != nil {
return nil, err
}
return &y, nil
}
func (s *Store) createFiscalYear(ctx context.Context, y *FiscalYear) error {
ctx, span := mongo.StartSpan(ctx, "Store.createFiscalYear")
defer span.End()
_, err := s.fiscalYears().InsertOne(ctx, y)
return err
}
func (s *Store) updateFiscalYearStatus(ctx context.Context, yearID, orgID string, status FiscalYearStatus, extraSet bson.M) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateFiscalYearStatus")
defer span.End()
set := bson.M{"status": status}
for k, v := range extraSet {
set[k] = v
}
_, err := s.fiscalYears().UpdateOne(ctx,
bson.M{"_id": yearID, "org_id": orgID},
bson.M{"$set": set},
)
return err
}
// ── Events ────────────────────────────────────────────────────────────────────
func (s *Store) getEvents(ctx context.Context, orgID, fiscalYearID string) ([]OrgEvent, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getEvents")
defer span.End()
q := bson.M{"org_id": orgID}
if fiscalYearID != "" {
q["fiscal_year_id"] = fiscalYearID
}
opts := options.Find().SetSort(bson.M{"date_start": 1})
cur, err := s.orgEvents().Find(ctx, q, opts)
if err != nil {
return nil, fmt.Errorf("find events: %w", err)
}
defer cur.Close(ctx)
var events []OrgEvent
if err := cur.All(ctx, &events); err != nil {
return nil, fmt.Errorf("decode events: %w", err)
}
return events, nil
}
func (s *Store) getEvent(ctx context.Context, eventID, orgID string) (*OrgEvent, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getEvent")
defer span.End()
var e OrgEvent
if err := s.orgEvents().FindOne(ctx, bson.M{"_id": eventID, "org_id": orgID}).Decode(&e); err != nil {
return nil, fmt.Errorf("find event: %w", err)
}
return &e, nil
}
func (s *Store) createEvent(ctx context.Context, e *OrgEvent) error {
ctx, span := mongo.StartSpan(ctx, "Store.createEvent")
defer span.End()
_, err := s.orgEvents().InsertOne(ctx, e)
return err
}
func (s *Store) updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateEvent")
defer span.End()
_, err := s.orgEvents().UpdateOne(ctx,
bson.M{"_id": eventID, "org_id": orgID},
bson.M{"$set": update},
)
return err
}
func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent")
defer span.End()
_, err := s.orgEvents().DeleteOne(ctx, bson.M{"_id": eventID, "org_id": orgID})
return err
}
// ── Budget Lines ──────────────────────────────────────────────────────────────
func (s *Store) getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getBudgetLines")
defer span.End()
cur, err := s.budgetLines().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID})
if err != nil {
return nil, fmt.Errorf("find budget lines: %w", err)
}
defer cur.Close(ctx)
var lines []BudgetLine
if err := cur.All(ctx, &lines); err != nil {
return nil, fmt.Errorf("decode budget lines: %w", err)
}
return lines, nil
}
func (s *Store) createBudgetLine(ctx context.Context, l *BudgetLine) error {
ctx, span := mongo.StartSpan(ctx, "Store.createBudgetLine")
defer span.End()
_, err := s.budgetLines().InsertOne(ctx, l)
return err
}
func (s *Store) deleteBudgetLine(ctx context.Context, lineID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteBudgetLine")
defer span.End()
_, err := s.budgetLines().DeleteOne(ctx, bson.M{"_id": lineID, "org_id": orgID})
return err
}
// ── Event Comments ────────────────────────────────────────────────────────────
func (s *Store) getEventComments(ctx context.Context, eventID, orgID string) ([]EventComment, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getEventComments")
defer span.End()
opts := options.Find().SetSort(bson.M{"created_at": 1})
cur, err := s.eventComments().Find(ctx, bson.M{"event_id": eventID, "org_id": orgID}, opts)
if err != nil {
return nil, fmt.Errorf("find event comments: %w", err)
}
defer cur.Close(ctx)
var comments []EventComment
if err := cur.All(ctx, &comments); err != nil {
return nil, fmt.Errorf("decode event comments: %w", err)
}
return comments, nil
}
func (s *Store) createEventComment(ctx context.Context, c *EventComment) error {
ctx, span := mongo.StartSpan(ctx, "Store.createEventComment")
defer span.End()
_, err := s.eventComments().InsertOne(ctx, c)
return err
}
// ── Transaction Requests ──────────────────────────────────────────────────────
func (s *Store) getTxRequests(ctx context.Context, orgID string, filter bson.M) ([]TxRequest, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTxRequests")
defer span.End()
q := bson.M{"org_id": orgID}
for k, v := range filter {
q[k] = v
}
opts := options.Find().SetSort(bson.M{"created_at": -1})
cur, err := s.txRequests().Find(ctx, q, opts)
if err != nil {
return nil, fmt.Errorf("find tx requests: %w", err)
}
defer cur.Close(ctx)
var reqs []TxRequest
if err := cur.All(ctx, &reqs); err != nil {
return nil, fmt.Errorf("decode tx requests: %w", err)
}
return reqs, nil
}
func (s *Store) getTxRequest(ctx context.Context, reqID, orgID string) (*TxRequest, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getTxRequest")
defer span.End()
var r TxRequest
if err := s.txRequests().FindOne(ctx, bson.M{"_id": reqID, "org_id": orgID}).Decode(&r); err != nil {
return nil, fmt.Errorf("find tx request: %w", err)
}
return &r, nil
}
func (s *Store) createTxRequest(ctx context.Context, r *TxRequest) error {
ctx, span := mongo.StartSpan(ctx, "Store.createTxRequest")
defer span.End()
_, err := s.txRequests().InsertOne(ctx, r)
return err
}
func (s *Store) appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error {
ctx, span := mongo.StartSpan(ctx, "Store.appendStatusLog")
defer span.End()
_, err := s.txRequests().UpdateOne(ctx,
bson.M{"_id": reqID, "org_id": orgID},
bson.M{"$push": bson.M{"status_log": entry}},
)
return err
}
func (s *Store) updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateTxRequest")
defer span.End()
_, err := s.txRequests().UpdateOne(ctx,
bson.M{"_id": reqID, "org_id": orgID},
bson.M{"$set": update},
)
return err
}

View File

@ -504,6 +504,7 @@
</div> </div>
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a> <a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">Organisations</a>
{{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}} {{$settingsActive := or (eq .Route "settings") (eq .Route "auto-import")}}
<div class="nav-group"> <div class="nav-group">
@ -531,6 +532,7 @@
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a> <a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a> <a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a> <a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">Organisations</a>
<hr> <hr>
<span class="nav-drawer-section-label">Analysis</span> <span class="nav-drawer-section-label">Analysis</span>
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a> <a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>

View File

@ -0,0 +1,43 @@
{{define "content"}}
<div style="max-width:480px; margin:0 auto;">
<h1 style="margin-bottom:24px;">New Organisation</h1>
{{if .Error}}
<div class="error" style="margin-bottom:16px; padding:12px 16px; background:var(--red-dim); border-radius:var(--radius-sm);">{{.Error}}</div>
{{end}}
<div class="card">
<form method="post" action="/orgs/new">
<div style="margin-bottom:20px;">
<label class="form-label">Organisation name</label>
<input class="form-input" type="text" name="name" value="{{.Name}}" placeholder="Scouts Group Lisboa" required autofocus
oninput="autoSlug(this)">
</div>
<div style="margin-bottom:24px;">
<label class="form-label">URL slug <span style="color:var(--text3); font-weight:400;">(used in all links)</span></label>
<div style="display:flex; align-items:center; gap:0; border:1px solid var(--border2); border-radius:var(--radius-sm); overflow:hidden; background:var(--bg3);">
<span style="padding:0 10px; color:var(--text3); font-size:13px; white-space:nowrap; border-right:1px solid var(--border2);">finance.local/orgs/</span>
<input style="flex:1; border:none; background:transparent; padding:10px 12px; color:var(--text); font-size:13px; outline:none;" type="text" name="slug" id="slug" value="{{.Slug}}" placeholder="scouts-lisboa" required pattern="[a-z0-9-]{2,40}">
</div>
<div style="font-size:11px; color:var(--text3); margin-top:5px;">Lowercase letters, digits and hyphens only.</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Create organisation</button>
</form>
</div>
</div>
<script>
function autoSlug(nameInput) {
const slug = document.getElementById('slug');
if (slug.dataset.edited) return;
slug.value = nameInput.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
document.getElementById('slug').addEventListener('input', function() {
this.dataset.edited = '1';
});
</script>
{{end}}

View File

@ -0,0 +1,140 @@
{{define "content"}}
{{$d := .}}
<!-- Breadcrumb + actions -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<div>
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;"><a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /</div>
<h1>{{$d.Org.Name}}</h1>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="/orgs/{{$d.Org.Slug}}/teams" class="btn btn-outline btn-sm">Teams</a>
<a href="/orgs/{{$d.Org.Slug}}/members" class="btn btn-outline btn-sm">Members</a>
{{if eq $d.MyRole "admin"}}
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-primary btn-sm">+ Invite</a>
{{end}}
</div>
</div>
<!-- Stats row -->
<div class="grid" style="margin-bottom:24px;">
<div class="card value-card animate-on-scroll">
<h2>Teams</h2>
<div class="value" style="color:var(--text);">{{len $d.Teams}}</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>Members</h2>
<div class="value" style="color:var(--text);">{{len $d.Members}}</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>Fiscal years</h2>
<div class="value" style="color:var(--text);">{{len $d.FiscalYears}}</div>
</div>
</div>
<!-- Active fiscal year banner -->
{{if $d.ActiveYear}}
<div class="card animate-on-scroll" style="margin-bottom:24px; border-color:var(--accent); background:linear-gradient(135deg, var(--bg2), var(--bg3));">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;">
<div>
<div style="font-size:11px; font-weight:700; letter-spacing:.07em; color:var(--accent); margin-bottom:4px;">ACTIVE YEAR</div>
<div style="font-size:20px; font-weight:700;">{{$d.ActiveYear.Label}}</div>
<div style="font-size:12px; color:var(--text3); margin-top:4px;">
{{dateShort $d.ActiveYear.StartDate}} — {{dateShort $d.ActiveYear.EndDate}}
</div>
</div>
<div style="display:flex; gap:8px;">
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/events" class="btn btn-outline btn-sm">Events</a>
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/requests" class="btn btn-outline btn-sm">Requests</a>
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.ActiveYear.ID}}/ledger" class="btn btn-outline btn-sm">Ledger</a>
</div>
</div>
</div>
{{end}}
<!-- Fiscal years list -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h2>Fiscal years</h2>
{{if eq $d.MyRole "admin"}}
<button onclick="document.getElementById('new-year-modal').style.display='flex'" class="btn btn-outline btn-sm">+ New year</button>
{{end}}
</div>
{{if $d.FiscalYears}}
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
<table>
<thead>
<tr>
<th>Label</th>
<th>Period</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $d.FiscalYears}}
<tr>
<td style="font-weight:600;">{{.Label}}</td>
<td style="color:var(--text2); font-size:13px;">{{dateShort .StartDate}} — {{dateShort .EndDate}}</td>
<td>
{{if eq (print .Status) "active"}}
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:rgba(74,222,128,0.12); color:var(--green);">active</span>
{{else if eq (print .Status) "closed"}}
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:var(--bg3); color:var(--text3);">closed</span>
{{else}}
<span style="font-size:11px; font-weight:700; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">draft</span>
{{end}}
</td>
<td style="text-align:right;">
<a href="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/events" class="btn btn-outline btn-sm">View</a>
{{if and (eq (print .Status) "draft") (eq (print $d.MyRole) "admin")}}
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/activate" style="display:inline;" onsubmit="return confirm('Activate this fiscal year? All events must be approved first.')">
<button class="btn btn-primary btn-sm">Activate</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:36px; margin-bottom:12px;">📅</div>
<h3>No fiscal years yet</h3>
<p style="margin-bottom:16px;">Create a fiscal year to start planning events and budgets.</p>
{{if eq $d.MyRole "admin"}}
<button onclick="document.getElementById('new-year-modal').style.display='flex'" class="btn btn-primary">Create fiscal year</button>
{{end}}
</div>
{{end}}
<!-- New fiscal year modal -->
{{if eq $d.MyRole "admin"}}
<div id="new-year-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center;">
<div class="card" style="width:100%; max-width:420px; margin:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>New fiscal year</h2>
<button onclick="document.getElementById('new-year-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
</div>
<form method="post" action="/orgs/{{$d.Org.Slug}}/years">
<div style="margin-bottom:14px;">
<label class="form-label">Label</label>
<input class="form-input" type="text" name="label" placeholder="2025" required>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:20px;">
<div>
<label class="form-label">Start date</label>
<input class="form-input" type="date" name="start_date" required>
</div>
<div>
<label class="form-label">End date</label>
<input class="form-input" type="date" name="end_date" required>
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Create</button>
</form>
</div>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,90 @@
{{define "content"}}
{{$d := .}}
<div style="max-width:520px; margin:0 auto;">
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
<a href="/orgs/{{$d.Org.Slug}}/members" style="color:var(--text3); text-decoration:none;">Members</a> /
</div>
<h1 style="margin-bottom:24px;">Invite member</h1>
{{if $d.Link}}
<!-- Success state: show the generated link -->
<div class="card" style="border-color:var(--accent);">
<div style="font-size:13px; font-weight:600; color:var(--green); margin-bottom:12px;">✓ Invite created</div>
<p style="font-size:13px; color:var(--text2); margin-bottom:14px;">
Copy this link and send it to the invitee. It expires in 7 days.<br>
<span style="font-size:11px; color:var(--text3);">(Email delivery coming in a future update.)</span>
</p>
<div style="display:flex; gap:8px; align-items:center;">
<input id="invite-link" class="form-input" type="text" value="{{$d.Link}}" readonly
style="font-size:12px; font-family:monospace; flex:1;">
<button onclick="copyLink()" class="btn btn-primary btn-sm" id="copy-btn">Copy</button>
</div>
<div style="margin-top:20px; display:flex; gap:8px;">
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-outline btn-sm">Invite another</a>
<a href="/orgs/{{$d.Org.Slug}}/members" class="btn btn-outline btn-sm">Back to members</a>
</div>
</div>
<script>
function copyLink() {
const inp = document.getElementById('invite-link');
inp.select();
navigator.clipboard.writeText(inp.value).then(() => {
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
});
}
</script>
{{else}}
{{if $d.Error}}
<div class="error" style="margin-bottom:16px; padding:12px 16px; background:var(--red-dim); border-radius:var(--radius-sm);">{{$d.Error}}</div>
{{end}}
<div class="card">
<form method="post" action="/orgs/{{$d.Org.Slug}}/invite">
<div style="margin-bottom:16px;">
<label class="form-label">Email address</label>
<input class="form-input" type="email" name="email" placeholder="colleague@example.com" required autofocus>
</div>
<div style="margin-bottom:16px;">
<label class="form-label">Role</label>
<select class="form-input" name="role">
<option value="member">Member — submit requests, manage own team events</option>
<option value="viewer">Viewer — read-only access to own team</option>
<option value="finance">Finance — approve requests, manage ledger</option>
<option value="admin">Admin — full control</option>
</select>
<div style="font-size:11px; color:var(--text3); margin-top:5px;">
All financial approvals are handled by finance/admin regardless of team.
</div>
</div>
{{if $d.Teams}}
<div style="margin-bottom:20px;">
<label class="form-label">Team assignment <span style="color:var(--text3); font-weight:400;">(optional — leave blank for org-wide access)</span></label>
<div style="display:flex; flex-direction:column; gap:8px; margin-top:8px;">
{{range $d.Teams}}
<label style="display:flex; align-items:center; gap:10px; cursor:pointer; font-size:13px;">
<input type="checkbox" name="team_ids" value="{{.ID}}"
style="width:15px; height:15px; accent-color:var(--accent);">
<span>{{.Name}}</span>
{{if eq (print .Type) "guest"}}
<span style="font-size:10px; padding:2px 7px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span>
{{end}}
</label>
{{end}}
</div>
</div>
{{end}}
<button type="submit" class="btn btn-primary" style="width:100%;">Generate invite link</button>
</form>
</div>
{{end}}
</div>
{{end}}

View File

@ -0,0 +1,33 @@
{{define "content"}}
{{$d := .}}
<div style="max-width:440px; margin:0 auto; padding-top:32px;">
<div class="card" style="text-align:center;">
<div style="font-size:48px; margin-bottom:16px;">🏢</div>
<h1 style="font-size:22px; margin-bottom:8px;">You've been invited</h1>
<p style="color:var(--text2); font-size:14px; margin-bottom:24px;">
Join <strong style="color:var(--text);">{{$d.Org.Name}}</strong> as
<strong style="color:var(--accent2);">{{$d.Invite.Role}}</strong>
</p>
{{if $d.Invite.TeamIDs}}
<div style="margin-bottom:20px; padding:12px 16px; background:var(--bg3); border-radius:var(--radius-sm); text-align:left;">
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">ASSIGNED TEAMS</div>
{{range $d.Invite.TeamIDs}}
<div style="font-size:13px; color:var(--text2); padding:2px 0;">· {{.}}</div>
{{end}}
</div>
{{end}}
<p style="font-size:12px; color:var(--text3); margin-bottom:24px;">
Joining as <strong style="color:var(--text);">{{$d.Email}}</strong>.<br>
This invite expires {{dateShort $d.Invite.ExpiresAt}}.
</p>
<form method="post" action="/join/{{$d.Invite.Token}}">
<button type="submit" class="btn btn-primary" style="width:100%; margin-bottom:10px;">Accept &amp; join</button>
</form>
<a href="/" style="font-size:13px; color:var(--text3);">Decline</a>
</div>
</div>
{{end}}

View File

@ -0,0 +1,30 @@
{{define "content"}}
{{$d := .}}
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px;">
<h1>Organisations</h1>
<a href="/orgs/new" class="btn btn-primary">+ New organisation</a>
</div>
{{if $d.Orgs}}
<div style="display:flex; flex-direction:column; gap:12px;">
{{range $d.Orgs}}
<a href="/orgs/{{.Org.Slug}}" style="text-decoration:none;">
<div class="card" style="display:flex; justify-content:space-between; align-items:center; padding:18px 24px; cursor:pointer; transition:border-color 0.15s;" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor=''">
<div>
<div style="font-size:16px; font-weight:700; color:var(--text);">{{.Org.Name}}</div>
<div style="font-size:12px; color:var(--text3); margin-top:3px;">{{.Org.Slug}}</div>
</div>
<span style="font-size:11px; font-weight:600; padding:4px 10px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{.Role}}</span>
</div>
</a>
{{end}}
</div>
{{else}}
<div class="card empty-state">
<div style="font-size:48px; margin-bottom:16px;">🏢</div>
<h3>No organisations yet</h3>
<p style="margin-bottom:20px;">Create one to start planning team finances.</p>
<a href="/orgs/new" class="btn btn-primary">Create organisation</a>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,99 @@
{{define "content"}}
{{$d := .}}
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<div>
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
</div>
<h1>Members</h1>
</div>
{{if eq $d.MyRole "admin"}}
<a href="/orgs/{{$d.Org.Slug}}/invite" class="btn btn-primary">+ Invite member</a>
{{end}}
</div>
<!-- Members table -->
<div class="card animate-on-scroll" style="padding:0; overflow:hidden; margin-bottom:24px;">
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Teams</th>
{{if eq $d.MyRole "admin"}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range $m := $d.Members}}
<tr>
<td style="font-weight:500;">{{$m.Email}}</td>
<td>
{{if eq $d.MyRole "admin"}}
<form method="post" action="/orgs/{{$d.Org.Slug}}/members/{{$m.ID}}/role" style="display:inline;">
<select name="role" onchange="this.form.submit()" style="background:var(--bg3); border:1px solid var(--border2); color:var(--text); border-radius:6px; padding:3px 6px; font-size:12px; cursor:pointer;">
<option value="admin" {{if eq $m.Role "admin"}}selected{{end}}>admin</option>
<option value="finance" {{if eq $m.Role "finance"}}selected{{end}}>finance</option>
<option value="member" {{if eq $m.Role "member"}}selected{{end}}>member</option>
<option value="viewer" {{if eq $m.Role "viewer"}}selected{{end}}>viewer</option>
</select>
</form>
{{else}}
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{$m.Role}}</span>
{{end}}
</td>
<td style="font-size:12px; color:var(--text2);">
{{if $m.TeamIDs}}
{{range $i, $tid := $m.TeamIDs}}
{{range $d.Teams}}{{if eq .ID $tid}}{{if $i}}, {{end}}{{.Name}}{{end}}{{end}}
{{end}}
{{else}}
<span style="color:var(--text3);">all teams</span>
{{end}}
</td>
{{if eq $d.MyRole "admin"}}
<td style="text-align:right;">
<form method="post" action="/orgs/{{$d.Org.Slug}}/members/{{$m.ID}}/remove" style="display:inline;"
onsubmit="return confirm('Remove {{$m.Email}} from this organisation?')">
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Remove</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
<!-- Pending invites -->
{{if and (eq $d.MyRole "admin") $d.Invites}}
<h2 style="margin-bottom:12px;">Pending invites</h2>
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $d.Invites}}
<tr>
<td style="font-weight:500;">{{.Email}}</td>
<td><span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">{{.Role}}</span></td>
<td style="font-size:12px; color:var(--text3);">{{dateShort .ExpiresAt}}</td>
<td style="text-align:right;">
<form method="post" action="/orgs/{{$d.Org.Slug}}/invites/{{.ID}}/revoke" style="display:inline;">
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Revoke</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,90 @@
{{define "content"}}
{{$d := .}}
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<div>
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Organisations</a> /
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
</div>
<h1>Teams</h1>
</div>
{{if eq $d.MyRole "admin"}}
<button onclick="document.getElementById('new-team-modal').style.display='flex'" class="btn btn-primary">+ New team</button>
{{end}}
</div>
{{if $d.Teams}}
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Members</th>
{{if eq $d.MyRole "admin"}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range $t := $d.Teams}}
{{$count := 0}}
{{range $d.Members}}{{range .TeamIDs}}{{if eq . $t.ID}}{{$count = add $count 1}}{{end}}{{end}}{{end}}
<tr>
<td style="font-weight:600;">{{$t.Name}}</td>
<td>
{{if eq (print $t.Type) "guest"}}
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span>
{{else}}
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">internal</span>
{{end}}
</td>
<td style="color:var(--text2);">{{$count}}</td>
{{if eq $d.MyRole "admin"}}
<td style="text-align:right;">
<form method="post" action="/orgs/{{$d.Org.Slug}}/teams/{{$t.ID}}/delete" style="display:inline;"
onsubmit="return confirm('Delete team {{$t.Name}}? Members will lose team assignment.')">
<button class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red);">Delete</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:36px; margin-bottom:12px;">👥</div>
<h3>No teams yet</h3>
<p style="margin-bottom:16px;">Teams group members and own events. Guest teams have scoped visibility.</p>
{{if eq $d.MyRole "admin"}}
<button onclick="document.getElementById('new-team-modal').style.display='flex'" class="btn btn-primary">Create first team</button>
{{end}}
</div>
{{end}}
{{if eq $d.MyRole "admin"}}
<div id="new-team-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center;">
<div class="card" style="width:100%; max-width:400px; margin:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>New team</h2>
<button onclick="document.getElementById('new-team-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
</div>
<form method="post" action="/orgs/{{$d.Org.Slug}}/teams">
<div style="margin-bottom:14px;">
<label class="form-label">Team name</label>
<input class="form-input" type="text" name="name" placeholder="Marketing" required autofocus>
</div>
<div style="margin-bottom:20px;">
<label class="form-label">Type</label>
<select class="form-input" name="type">
<option value="internal">Internal — full org visibility</option>
<option value="guest">Guest — sees own team data only</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Create team</button>
</form>
</div>
</div>
{{end}}
{{end}}