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:
parent
1b9284801c
commit
6ed848a001
@ -127,6 +127,15 @@ var (
|
||||
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
|
||||
peopleTmpl = parseTmpl("templates/base.html", "templates/people.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 {
|
||||
@ -194,6 +203,47 @@ type storeIface interface {
|
||||
getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error)
|
||||
createImportSchedule(ctx context.Context, sched *ImportSchedule) 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 {
|
||||
@ -2488,6 +2538,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /tax", h.Tax)
|
||||
mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
|
||||
mux.HandleFunc("GET /auto-import", h.AutoImport)
|
||||
|
||||
h.RegisterOrgRoutes(mux)
|
||||
}
|
||||
|
||||
func sortStrings(s []string) {
|
||||
|
||||
570
apps/finance/services/api/main/handler_org.go
Normal file
570
apps/finance/services/api/main/handler_org.go
Normal 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 2–40 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)
|
||||
}
|
||||
@ -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) 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func newHandler(store *mockStore) *Handler {
|
||||
|
||||
356
apps/finance/services/api/main/models_org.go
Normal file
356
apps/finance/services/api/main/models_org.go
Normal 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
|
||||
}
|
||||
529
apps/finance/services/api/main/store_org.go
Normal file
529
apps/finance/services/api/main/store_org.go
Normal 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
|
||||
}
|
||||
@ -504,6 +504,7 @@
|
||||
</div>
|
||||
|
||||
<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")}}
|
||||
<div class="nav-group">
|
||||
@ -531,6 +532,7 @@
|
||||
<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="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
|
||||
<a href="/orgs" class="{{if eq .Route "orgs"}}active{{end}}">Organisations</a>
|
||||
<hr>
|
||||
<span class="nav-drawer-section-label">Analysis</span>
|
||||
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
|
||||
|
||||
43
apps/finance/services/api/main/templates/org_create.html
Normal file
43
apps/finance/services/api/main/templates/org_create.html
Normal 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}}
|
||||
140
apps/finance/services/api/main/templates/org_home.html
Normal file
140
apps/finance/services/api/main/templates/org_home.html
Normal 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}}
|
||||
90
apps/finance/services/api/main/templates/org_invite.html
Normal file
90
apps/finance/services/api/main/templates/org_invite.html
Normal 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}}
|
||||
33
apps/finance/services/api/main/templates/org_join.html
Normal file
33
apps/finance/services/api/main/templates/org_join.html
Normal 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 & join</button>
|
||||
</form>
|
||||
<a href="/" style="font-size:13px; color:var(--text3);">Decline</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
30
apps/finance/services/api/main/templates/org_list.html
Normal file
30
apps/finance/services/api/main/templates/org_list.html
Normal 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}}
|
||||
99
apps/finance/services/api/main/templates/org_members.html
Normal file
99
apps/finance/services/api/main/templates/org_members.html
Normal 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}}
|
||||
90
apps/finance/services/api/main/templates/org_teams.html
Normal file
90
apps/finance/services/api/main/templates/org_teams.html
Normal 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}}
|
||||
Loading…
x
Reference in New Issue
Block a user