Browse Source

feat: added user creation/update

production
nic 4 months ago
parent
commit
f5160a95b6
  1. 3
      apps/web/main.go
  2. BIN
      apps/web/web-app
  3. 9
      go.mod
  4. 19
      go.sum
  5. 481
      internal/api/users.go
  6. 807
      internal/handlers/web/invoice_clock_report.go
  7. 862
      internal/handlers/web/users.go
  8. 36
      internal/middleware/csrf_simple.go
  9. 31
      templates/generic.html
  10. 3
      templates/layout.html
  11. 132
      templates/users.html
  12. 152
      templates/users_update.html

3
apps/web/main.go

@ -145,6 +145,9 @@ func main() {
protected.HandleFunc("/services", web.ServicesHandler).Methods("GET")
protected.HandleFunc("/tags", web.TagsHandler).Methods("GET")
protected.HandleFunc("/users", web.UsersHandler).Methods("GET")
protected.HandleFunc("/users/upload", web.UsersUploadHandler).Methods("POST")
protected.HandleFunc("/users/update", web.UsersUpdateHandler).Methods("GET")
protected.HandleFunc("/users/update/upload", web.UsersUpdateUploadHandler).Methods("POST")
// Document upload routes
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")

BIN
apps/web/web-app

Binary file not shown.

9
go.mod

@ -11,4 +11,13 @@ require (
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
github.com/xuri/excelize/v2 v2.8.1 // indirect
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

19
go.sum

@ -10,3 +10,22 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=

481
internal/api/users.go

@ -0,0 +1,481 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// CreateUserPayload represents the payload required to create a ServiceTrade user.
type CreateUserPayload struct {
Username string `json:"username"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Password string `json:"password"`
Email string `json:"email"`
Phone string `json:"phone,omitempty"`
CompanyID int `json:"companyId"`
LocationID int `json:"locationId"`
Details string `json:"details,omitempty"`
Status string `json:"status,omitempty"`
IsSales *bool `json:"isSales,omitempty"`
Timezone string `json:"timezone,omitempty"`
ServiceLineIDs []int `json:"serviceLineIds,omitempty"`
ManagerID *int `json:"managerId,omitempty"`
MFARequired *bool `json:"mfaRequired,omitempty"`
}
// UserRecord represents a simplified ServiceTrade user returned from the API.
type UserRecord struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Status string `json:"status"`
Roles []Role `json:"roles,omitempty"`
Raw []byte `json:"-"`
}
// Role represents a ServiceTrade role.
type Role struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Activities []string `json:"activities,omitempty"`
Global bool `json:"global"`
}
// UpdateUserPayload represents the fields that can be updated on an existing ServiceTrade user.
type UpdateUserPayload struct {
Username *string `json:"username,omitempty"`
FirstName *string `json:"firstName,omitempty"`
LastName *string `json:"lastName,omitempty"`
Password *string `json:"password,omitempty"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
CompanyID *int `json:"companyId,omitempty"`
LocationID *int `json:"locationId,omitempty"`
Details *string `json:"details,omitempty"`
Status *string `json:"status,omitempty"`
IsSales *bool `json:"isSales,omitempty"`
Timezone *string `json:"timezone,omitempty"`
ServiceLineIDs *[]int `json:"serviceLineIds,omitempty"`
ManagerID *int `json:"managerId,omitempty"`
MFARequired *bool `json:"mfaRequired,omitempty"`
}
// CreateUser creates a new ServiceTrade user and returns the created user record.
func (s *Session) CreateUser(payload CreateUserPayload) (*UserRecord, error) {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("error marshalling user payload: %w", err)
}
resp, err := s.DoRequest(http.MethodPost, "/user", bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("error sending create user request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading create user response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("create user failed: %s - %s", resp.Status, string(responseBody))
}
user, err := parseUserFromResponse(responseBody)
if err != nil {
return nil, fmt.Errorf("unable to parse create user response: %w", err)
}
user.Raw = responseBody
return user, nil
}
// AssignRoleToUser assigns a role to a ServiceTrade user.
func (s *Session) AssignRoleToUser(userID, roleID int) error {
endpoint := fmt.Sprintf("/user/%d/role/%d", userID, roleID)
resp, err := s.DoRequest(http.MethodPost, endpoint, nil)
if err != nil {
return fmt.Errorf("error assigning role %d to user %d: %w", roleID, userID, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("assign role failed: %s - %s", resp.Status, string(body))
}
return nil
}
// ListRoles retrieves all ServiceTrade roles visible to the authenticated account.
func (s *Session) ListRoles() ([]Role, error) {
resp, err := s.DoRequest(http.MethodGet, "/role", nil)
if err != nil {
return nil, fmt.Errorf("error requesting roles: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading roles response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list roles failed: %s - %s", resp.Status, string(body))
}
roles, err := parseRolesFromResponse(body)
if err != nil {
return nil, err
}
return roles, nil
}
// UpdateUser updates an existing ServiceTrade user with the provided fields.
func (s *Session) UpdateUser(userID int, payload UpdateUserPayload) (*UserRecord, error) {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("error marshalling user update payload: %w", err)
}
endpoint := fmt.Sprintf("/user/%d", userID)
resp, err := s.DoRequest(http.MethodPut, endpoint, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, fmt.Errorf("error sending update user request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading update user response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update user failed: %s - %s", resp.Status, string(responseBody))
}
user, err := parseUserFromResponse(responseBody)
if err != nil {
return nil, fmt.Errorf("unable to parse update user response: %w", err)
}
user.Raw = responseBody
return user, nil
}
// FindUserByUsername searches for a user by username. Returns an error if not exactly one match is found.
func (s *Session) FindUserByUsername(username string) (*UserRecord, error) {
if strings.TrimSpace(username) == "" {
return nil, fmt.Errorf("username is required")
}
query := url.Values{}
query.Set("search", username)
query.Set("searchFields", "username")
query.Set("status", "active,inactive,pending")
endpoint := fmt.Sprintf("/user?%s", query.Encode())
resp, err := s.DoRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("error searching for user %q: %w", username, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading user search response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("user search failed: %s - %s", resp.Status, string(body))
}
users, err := parseUsersFromResponse(body)
if err != nil {
return nil, fmt.Errorf("error parsing user search response: %w", err)
}
var matches []*UserRecord
for i := range users {
if strings.EqualFold(users[i].Username, username) {
matches = append(matches, &users[i])
}
}
if len(matches) == 0 {
return nil, fmt.Errorf("no user found with username %q", username)
}
if len(matches) > 1 {
return nil, fmt.Errorf("multiple users found with username %q", username)
}
return matches[0], nil
}
func parseUserFromResponse(body []byte) (*UserRecord, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding user response: %w", err)
}
candidate := extractMap(root, "data", "user")
if candidate == nil {
candidate = extractFirstMapFromArray(root, "data", "users")
}
if candidate == nil {
if userVal, ok := root["user"].(map[string]interface{}); ok {
candidate = userVal
}
}
if candidate == nil {
if dataVal, ok := root["data"].(map[string]interface{}); ok {
candidate = dataVal
}
}
if candidate == nil {
candidate = root
}
return buildUserRecord(candidate)
}
func parseUsersFromResponse(body []byte) ([]UserRecord, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding users response: %w", err)
}
var records []interface{}
if dataMap, ok := root["data"].(map[string]interface{}); ok {
if list, ok := dataMap["users"].([]interface{}); ok {
records = list
} else if list, ok := dataMap["records"].([]interface{}); ok {
records = list
}
}
if records == nil {
if list, ok := root["users"].([]interface{}); ok {
records = list
}
}
if records == nil {
return []UserRecord{}, nil
}
results := make([]UserRecord, 0, len(records))
for _, item := range records {
userMap, ok := item.(map[string]interface{})
if !ok {
continue
}
user, err := buildUserRecord(userMap)
if err != nil {
return nil, err
}
results = append(results, *user)
}
return results, nil
}
func parseRolesFromResponse(body []byte) ([]Role, error) {
var root map[string]interface{}
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("error decoding roles response: %w", err)
}
var rolesList []interface{}
if dataMap, ok := root["data"].(map[string]interface{}); ok {
if list, ok := dataMap["roles"].([]interface{}); ok {
rolesList = list
} else if list, ok := dataMap["records"].([]interface{}); ok {
rolesList = list
}
}
if rolesList == nil {
if list, ok := root["roles"].([]interface{}); ok {
rolesList = list
}
}
if rolesList == nil {
return nil, fmt.Errorf("roles not found in response")
}
return convertRoles(rolesList), nil
}
func extractMap(root map[string]interface{}, keys ...string) map[string]interface{} {
current := root
for _, key := range keys {
val, ok := current[key]
if !ok {
return nil
}
if nextMap, ok := val.(map[string]interface{}); ok {
current = nextMap
} else {
return nil
}
}
return current
}
func extractFirstMapFromArray(root map[string]interface{}, keys ...string) map[string]interface{} {
if len(keys) == 0 {
return nil
}
lastKey := keys[len(keys)-1]
parent := extractMap(root, keys[:len(keys)-1]...)
if parent == nil {
return nil
}
val, ok := parent[lastKey]
if !ok {
return nil
}
arr, ok := val.([]interface{})
if !ok || len(arr) == 0 {
return nil
}
first, ok := arr[0].(map[string]interface{})
if !ok {
return nil
}
return first
}
func convertRoles(items []interface{}) []Role {
var roles []Role
for _, item := range items {
roleMap, ok := item.(map[string]interface{})
if !ok {
continue
}
role := Role{
ID: toInt(roleMap["id"]),
Name: toString(roleMap["name"]),
Description: toString(roleMap["description"]),
Global: toBool(roleMap["global"]),
Activities: toStringSlice(roleMap["activities"]),
}
roles = append(roles, role)
}
return roles
}
func toInt(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
i, _ := v.Int64()
return int(i)
case string:
if v == "" {
return 0
}
i, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return i
default:
return 0
}
}
func toString(value interface{}) string {
switch v := value.(type) {
case string:
return v
case json.Number:
return v.String()
case fmt.Stringer:
return v.String()
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
default:
return ""
}
}
func toBool(value interface{}) bool {
switch v := value.(type) {
case bool:
return v
case string:
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "y":
return true
case "false", "0", "no", "n":
return false
}
case float64:
return v != 0
case int:
return v != 0
case json.Number:
i, err := v.Int64()
if err == nil {
return i != 0
}
}
return false
}
func toStringSlice(value interface{}) []string {
switch v := value.(type) {
case []string:
return v
case []interface{}:
result := make([]string, 0, len(v))
for _, item := range v {
result = append(result, toString(item))
}
return result
default:
return nil
}
}
func buildUserRecord(candidate map[string]interface{}) (*UserRecord, error) {
if candidate == nil {
return nil, fmt.Errorf("user data missing in response")
}
user := &UserRecord{
ID: toInt(candidate["id"]),
Username: toString(candidate["username"]),
Email: toString(candidate["email"]),
FirstName: toString(candidate["firstName"]),
LastName: toString(candidate["lastName"]),
Status: toString(candidate["status"]),
}
if user.ID == 0 {
return nil, fmt.Errorf("user ID missing in response")
}
if rolesVal, ok := candidate["roles"].([]interface{}); ok {
user.Roles = convertRoles(rolesVal)
}
return user, nil
}

807
internal/handlers/web/invoice_clock_report.go

@ -4,30 +4,28 @@ import (
"bytes"
"encoding/csv"
"fmt"
"log"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/xuri/excelize/v2"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
)
var invoiceClockReportHeader = []string{
"invoice_number",
"invoice_id",
"job_id",
"job_number",
"assignment_type",
"vendor_name",
"tech_names",
"enroute_events",
"onsite_events",
"error",
var reportHeader = []string{
"Customer PO",
"id",
"Link",
"Clock In",
"Clock Out",
}
// InvoiceClockReportHandler processes uploaded CSVs of invoice numbers and returns a clock-event report.
// InvoiceClockReportHandler processes a CSV upload and returns a CSV matching the requested template.
func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
@ -59,272 +57,719 @@ func InvoiceClockReportHandler(w http.ResponseWriter, r *http.Request) {
return
}
if len(records) == 0 {
http.Error(w, "CSV file is empty", http.StatusBadRequest)
if len(records) < 2 {
http.Error(w, "CSV must include a header row and at least one data row", http.StatusBadRequest)
return
}
invoiceNumbers := extractInvoiceNumbers(records)
if len(invoiceNumbers) == 0 {
http.Error(w, "No invoice numbers found in CSV", http.StatusBadRequest)
header := records[0]
columns := detectInputColumns(header)
if columns.jobID == -1 && columns.invoice == -1 {
http.Error(w, "Input must include a job id column or an invoice number column", http.StatusBadRequest)
return
}
var buffer bytes.Buffer
writer := csv.NewWriter(&buffer)
log.Printf("Invoice clock report: detected columns jobID=%d invoice=%d customerPO=%d",
columns.jobID, columns.invoice, columns.customerPO)
if err := writer.Write(invoiceClockReportHeader); err != nil {
http.Error(w, fmt.Sprintf("Error writing CSV header: %v", err), http.StatusInternalServerError)
return
tasks := make([]rowTask, 0, len(records)-1)
for rowIdx := 1; rowIdx < len(records); rowIdx++ {
row := ensureRowLength(records[rowIdx], len(header))
task := rowTask{
index: rowIdx - 1,
rawID: cleanIdentifier(getRowValue(row, columns.jobID)),
invoiceNumber: strings.TrimSpace(getRowValue(row, columns.invoice)),
customerPO: strings.TrimSpace(getRowValue(row, columns.customerPO)),
}
tasks = append(tasks, task)
}
f := excelize.NewFile()
sheet := f.GetSheetName(0)
f.SetSheetName(sheet, "Report")
sheet = "Report"
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 12},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
WrapText: true,
},
})
textStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 49, // treat as text
Alignment: &excelize.Alignment{
Vertical: "top",
WrapText: true,
},
})
bodyWrapStyle, _ := f.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Vertical: "top",
WrapText: true,
},
})
for idx, title := range reportHeader {
cell, _ := excelize.CoordinatesToCellName(idx+1, 1)
if err := f.SetCellStr(sheet, cell, title); err != nil {
http.Error(w, fmt.Sprintf("Error writing header cell: %v", err), http.StatusInternalServerError)
return
}
if err := f.SetCellStyle(sheet, cell, cell, headerStyle); err != nil {
http.Error(w, fmt.Sprintf("Error styling header cell: %v", err), http.StatusInternalServerError)
return
}
}
if err := f.SetPanes(sheet, &excelize.Panes{
Freeze: true,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
}); err != nil {
log.Printf("Warning: unable to freeze header row: %v", err)
}
if err := f.SetColWidth(sheet, "A", "A", 25); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
if err := f.SetColWidth(sheet, "B", "C", 40); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
if err := f.SetColWidth(sheet, "D", "E", 28); err != nil {
log.Printf("Warning: unable to set column width: %v", err)
}
for _, invoiceNumber := range invoiceNumbers {
row := buildInvoiceClockReportRow(session, invoiceNumber)
if err := writer.Write(row); err != nil {
http.Error(w, fmt.Sprintf("Error writing CSV row: %v", err), http.StatusInternalServerError)
if len(tasks) == 0 {
var buffer bytes.Buffer
if err := f.Write(&buffer); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return
}
if err := f.Close(); err != nil {
log.Printf("Warning: error closing workbook: %v", err)
}
filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
return
}
limiter := newRateLimiter(5)
defer limiter.Stop()
ctx := &workerContext{
session: session,
caches: newSharedCaches(),
limiter: limiter,
lookup: newLookupState(5),
}
workerCount := 6
if len(tasks) < workerCount {
workerCount = len(tasks)
}
if workerCount < 1 {
workerCount = 1
}
writer.Flush()
if err := writer.Error(); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing CSV: %v", err), http.StatusInternalServerError)
jobsCh := make(chan rowTask)
resultsCh := make(chan rowResult, workerCount)
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range jobsCh {
resultsCh <- processRow(task, ctx)
}
}()
}
go func() {
for _, task := range tasks {
jobsCh <- task
}
close(jobsCh)
}()
go func() {
wg.Wait()
close(resultsCh)
}()
pending := make(map[int]rowResult)
nextIndex := 0
var writeErr error
for res := range resultsCh {
if writeErr != nil {
continue
}
pending[res.index] = res
for {
data, ok := pending[nextIndex]
if !ok {
break
}
if err := writeExcelRow(f, sheet, data, nextIndex, textStyle, bodyWrapStyle); err != nil {
writeErr = err
break
}
delete(pending, nextIndex)
nextIndex++
}
}
if writeErr != nil {
http.Error(w, fmt.Sprintf("Error writing XLSX: %v", writeErr), http.StatusInternalServerError)
return
}
filename := fmt.Sprintf("invoice-clock-report-%s.csv", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "text/csv")
var buffer bytes.Buffer
if err := f.Write(&buffer); err != nil {
http.Error(w, fmt.Sprintf("Error finalizing XLSX: %v", err), http.StatusInternalServerError)
return
}
if err := f.Close(); err != nil {
log.Printf("Warning: error closing workbook: %v", err)
}
filename := fmt.Sprintf("invoice-clock-report-%s.xlsx", time.Now().Format("20060102-150405"))
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
w.Header().Set("Cache-Control", "no-store")
w.Write(buffer.Bytes())
}
func extractInvoiceNumbers(records [][]string) []string {
if len(records) == 0 {
return nil
type inputColumns struct {
jobID int
invoice int
customerPO int
}
func detectInputColumns(headers []string) inputColumns {
var cols inputColumns
cols.jobID = -1
cols.invoice = -1
cols.customerPO = -1
normalized := make([]string, len(headers))
original := make([]string, len(headers))
for i, header := range headers {
normalized[i] = normalizeHeaderValue(header)
original[i] = strings.ToLower(strings.TrimSpace(header))
}
headers := make([]string, len(records[0]))
for i, header := range records[0] {
headers[i] = strings.ToLower(strings.TrimSpace(header))
jobPriority := []string{"id", "jobid", "job_id"}
for _, candidate := range jobPriority {
if cols.jobID != -1 {
break
}
for i, name := range normalized {
if name == candidate {
cols.jobID = i
break
}
}
}
columnIndex := 0
hasHeader := false
for i, header := range headers {
if header == "invoice_number" || header == "invoicenumber" || header == "invoice" {
columnIndex = i
hasHeader = true
if cols.jobID == -1 {
for i, name := range normalized {
if strings.Contains(name, "job") && strings.Contains(name, "id") {
cols.jobID = i
break
}
}
}
invoiceCandidates := []string{"invoicenumber", "invoice", "refnumber", "ref"}
for _, candidate := range invoiceCandidates {
if cols.invoice != -1 {
break
}
for i, name := range normalized {
if name == candidate {
cols.invoice = i
break
}
}
}
var numbers []string
startRow := 0
if hasHeader {
startRow = 1
customerExact := []string{"customer_po", "customerpo", "customer purchase order", "customer_po_number"}
for _, candidate := range customerExact {
if cols.customerPO != -1 {
break
}
for i, name := range original {
if name == candidate {
cols.customerPO = i
break
}
}
}
for _, row := range records[startRow:] {
if columnIndex >= len(row) {
continue
if cols.customerPO == -1 {
for i, name := range normalized {
if strings.Contains(name, "customerpo") && !strings.Contains(name, "postal") {
cols.customerPO = i
break
}
}
number := strings.TrimSpace(row[columnIndex])
if number != "" {
numbers = append(numbers, number)
}
if cols.customerPO == -1 && cols.invoice != -1 {
invoiceHeader := normalized[cols.invoice]
if strings.Contains(invoiceHeader, "customerpo") {
cols.customerPO = cols.invoice
}
}
return numbers
return cols
}
func buildInvoiceClockReportRow(session *api.Session, invoiceIdentifier string) []string {
row := make([]string, len(invoiceClockReportHeader))
row[0] = invoiceIdentifier
invoice, err := session.GetInvoice(invoiceIdentifier)
if err != nil {
row[len(row)-1] = fmt.Sprintf("invoice lookup error: %v", err)
return row
func normalizeHeaderValue(header string) string {
h := strings.ToLower(strings.TrimSpace(header))
replacements := []string{" ", "_", "-", "."}
for _, repl := range replacements {
h = strings.ReplaceAll(h, repl, "")
}
if invoice == nil {
row[len(row)-1] = "invoice not found"
return h
}
func ensureRowLength(row []string, length int) []string {
if len(row) >= length {
return row
}
res := make([]string, length)
copy(res, row)
return res
}
invoiceID := valueToString(invoice["id"])
row[1] = invoiceID
func getRowValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return row[index]
}
jobInfo, ok := invoice["job"].(map[string]interface{})
if !ok {
row[len(row)-1] = "invoice missing job information"
return row
func cleanIdentifier(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.HasPrefix(value, "'") {
value = strings.TrimPrefix(value, "'")
}
if dot := strings.IndexRune(value, '.'); dot > 0 {
if decimalPart := value[dot+1:]; len(decimalPart) == 0 || strings.Trim(decimalPart, "0") == "" {
return value[:dot]
}
}
return value
}
jobID := valueToString(jobInfo["id"])
jobNumber := valueToString(jobInfo["number"])
row[2] = jobID
row[3] = jobNumber
type rowTask struct {
index int
rawID string
invoiceNumber string
customerPO string
}
var errors []string
type rowResult struct {
index int
customerPO string
jobID string
link string
clockIn string
clockOut string
}
jobDetails, err := session.GetJobDetails(jobID)
if err != nil {
errors = append(errors, fmt.Sprintf("job details error: %v", err))
type workerContext struct {
session *api.Session
caches *sharedCaches
limiter *rateLimiter
lookup *lookupState
}
type sharedCaches struct {
mu sync.RWMutex
invoices map[string]map[string]interface{}
clocks map[string]clockSummary
}
func newSharedCaches() *sharedCaches {
return &sharedCaches{
invoices: make(map[string]map[string]interface{}),
clocks: make(map[string]clockSummary),
}
}
clockData, err := session.GetJobClockEvents(jobID)
if err != nil {
errors = append(errors, fmt.Sprintf("clock events error: %v", err))
func (c *sharedCaches) getInvoice(key string) (map[string]interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
inv, ok := c.invoices[key]
return inv, ok
}
func (c *sharedCaches) setInvoice(key string, invoice map[string]interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.invoices[key] = invoice
}
func (c *sharedCaches) getClock(jobID string) (clockSummary, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
summary, ok := c.clocks[jobID]
return summary, ok
}
func (c *sharedCaches) setClock(jobID string, summary clockSummary) {
c.mu.Lock()
defer c.mu.Unlock()
c.clocks[jobID] = summary
}
type lookupState struct {
mu sync.Mutex
jobIDEnabled bool
invoiceIDEnabled bool
jobIDFailures int
invoiceIDFailures int
threshold int
jobIDDisabledLogged bool
invoiceIDDisabledLogged bool
}
func newLookupState(threshold int) *lookupState {
return &lookupState{
jobIDEnabled: true,
invoiceIDEnabled: true,
threshold: threshold,
}
}
techNames := collectTechNames(jobDetails, clockData)
func (s *lookupState) shouldTryJobID() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.jobIDEnabled
}
vendorName := extractVendorName(jobDetails, invoice)
assignmentType := determineAssignmentType(techNames, vendorName)
func (s *lookupState) recordJobIDResult(success bool) {
s.mu.Lock()
defer s.mu.Unlock()
if success {
s.jobIDFailures = 0
if !s.jobIDEnabled {
s.jobIDEnabled = true
s.jobIDDisabledLogged = false
}
return
}
if !s.jobIDEnabled {
return
}
s.jobIDFailures++
if s.jobIDFailures >= s.threshold {
s.jobIDEnabled = false
if !s.jobIDDisabledLogged {
log.Printf("Disabling job ID lookups after %d consecutive failures", s.jobIDFailures)
s.jobIDDisabledLogged = true
}
}
}
row[4] = assignmentType
row[5] = vendorName
row[6] = strings.Join(techNames, ", ")
func (s *lookupState) shouldTryInvoiceID() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.invoiceIDEnabled
}
if clockData != nil {
row[7] = formatActivityEvents(clockData.PairedEvents, "enroute")
row[8] = formatActivityEvents(clockData.PairedEvents, "onsite")
func (s *lookupState) recordInvoiceIDResult(success bool) {
s.mu.Lock()
defer s.mu.Unlock()
if success {
s.invoiceIDFailures = 0
if !s.invoiceIDEnabled {
s.invoiceIDEnabled = true
s.invoiceIDDisabledLogged = false
}
return
}
if !s.invoiceIDEnabled {
return
}
s.invoiceIDFailures++
if s.invoiceIDFailures >= s.threshold {
s.invoiceIDEnabled = false
if !s.invoiceIDDisabledLogged {
log.Printf("Disabling invoice ID lookups after %d consecutive failures", s.invoiceIDFailures)
s.invoiceIDDisabledLogged = true
}
}
}
type rateLimiter struct {
ticker *time.Ticker
}
if len(errors) > 0 {
row[len(row)-1] = strings.Join(errors, "; ")
func newRateLimiter(rps int) *rateLimiter {
if rps <= 0 {
rps = 5
}
interval := time.Second / time.Duration(rps)
return &rateLimiter{ticker: time.NewTicker(interval)}
}
return row
func (rl *rateLimiter) Wait() {
if rl == nil || rl.ticker == nil {
return
}
<-rl.ticker.C
}
func collectTechNames(jobDetails map[string]interface{}, clockData *api.ClockEventData) []string {
seen := map[string]struct{}{}
func (rl *rateLimiter) Stop() {
if rl == nil || rl.ticker == nil {
return
}
rl.ticker.Stop()
}
if jobDetails != nil {
if appointments, ok := jobDetails["appointments"].([]interface{}); ok {
for _, appt := range appointments {
apptMap, ok := appt.(map[string]interface{})
if !ok {
continue
}
if techs, ok := apptMap["techs"].([]interface{}); ok {
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok {
name := strings.TrimSpace(valueToString(techMap["name"]))
if name != "" {
seen[name] = struct{}{}
}
}
}
}
func processRow(task rowTask, ctx *workerContext) rowResult {
result := rowResult{
index: task.index,
customerPO: strings.TrimSpace(task.customerPO),
}
rawID := strings.TrimSpace(task.rawID)
invoiceNumber := strings.TrimSpace(task.invoiceNumber)
displayRow := task.index + 1
if rawID != "" {
if ctx.lookup.shouldTryJobID() {
log.Printf("Row %d: attempting job clock lookup for id=%s", displayRow, rawID)
summary, err := fetchClockSummary(ctx.session, rawID, ctx.caches, ctx.limiter)
if err == nil {
ctx.lookup.recordJobIDResult(true)
result.jobID = rawID
result.link = buildJobLink(rawID)
result.clockIn = summary.ClockIn
result.clockOut = summary.ClockOut
finalizeCustomerPO(&result, invoiceNumber, rawID)
return result
}
log.Printf("Row %d: clock lookup failed for job %s: %v", displayRow, rawID, err)
ctx.lookup.recordJobIDResult(false)
} else {
log.Printf("Row %d: skipping job id lookup (disabled)", displayRow)
}
if current, ok := jobDetails["currentAppointment"].(map[string]interface{}); ok {
if techs, ok := current["techs"].([]interface{}); ok {
for _, tech := range techs {
if techMap, ok := tech.(map[string]interface{}); ok {
name := strings.TrimSpace(valueToString(techMap["name"]))
if name != "" {
seen[name] = struct{}{}
}
}
}
var invoiceData map[string]interface{}
if rawID != "" {
if ctx.lookup.shouldTryInvoiceID() {
log.Printf("Row %d: falling back to invoice id %s", displayRow, rawID)
inv, err := fetchInvoice(ctx.session, rawID, ctx.caches, ctx.limiter)
if err == nil && inv != nil {
ctx.lookup.recordInvoiceIDResult(true)
invoiceData = inv
} else {
if err != nil {
log.Printf("Invoice lookup failed for id %s (row %d): %v", rawID, displayRow, err)
}
ctx.lookup.recordInvoiceIDResult(false)
}
} else {
log.Printf("Row %d: skipping invoice id lookup (disabled)", displayRow)
}
}
if clockData != nil {
for _, event := range clockData.PairedEvents {
name := strings.TrimSpace(event.Start.User.Name)
if name != "" {
seen[name] = struct{}{}
}
name = strings.TrimSpace(event.End.User.Name)
if name != "" {
seen[name] = struct{}{}
}
if invoiceData == nil && invoiceNumber != "" {
log.Printf("Row %d: falling back to invoice number %s", displayRow, invoiceNumber)
inv, err := fetchInvoice(ctx.session, invoiceNumber, ctx.caches, ctx.limiter)
if err != nil {
log.Printf("Invoice lookup failed for number %s (row %d): %v", invoiceNumber, displayRow, err)
} else if inv != nil {
invoiceData = inv
}
}
if len(seen) == 0 {
return nil
if invoiceData != nil {
applyInvoiceFields(&result, invoiceData)
jobFromInvoice := extractJobIDFromInvoice(invoiceData)
if jobFromInvoice != "" {
summary, err := fetchClockSummary(ctx.session, jobFromInvoice, ctx.caches, ctx.limiter)
if err != nil {
log.Printf("Row %d: clock lookup failed for invoice-derived job %s: %v", displayRow, jobFromInvoice, err)
} else {
result.clockIn = summary.ClockIn
result.clockOut = summary.ClockOut
}
result.jobID = jobFromInvoice
result.link = buildJobLink(jobFromInvoice)
}
}
var names []string
for name := range seen {
names = append(names, name)
if result.jobID == "" {
log.Printf("Row %d: no job id could be determined; leaving link blank", displayRow)
}
sort.Strings(names)
return names
finalizeCustomerPO(&result, invoiceNumber, rawID)
return result
}
func extractVendorName(jobDetails map[string]interface{}, invoice map[string]interface{}) string {
if jobDetails != nil {
if vendor, ok := jobDetails["vendor"].(map[string]interface{}); ok {
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
return name
}
func applyInvoiceFields(result *rowResult, invoice map[string]interface{}) {
if invoice == nil {
return
}
if result.customerPO == "" {
if po := strings.TrimSpace(valueToString(invoice["customerPo"])); po != "" {
result.customerPO = po
}
}
}
func finalizeCustomerPO(result *rowResult, invoiceNumber, rawID string) {
if result.customerPO != "" {
return
}
if invoiceNumber != "" {
result.customerPO = invoiceNumber
return
}
if rawID != "" {
result.customerPO = rawID
}
}
if vendor, ok := invoice["vendor"].(map[string]interface{}); ok {
if name := strings.TrimSpace(valueToString(vendor["name"])); name != "" {
return name
func buildJobLink(jobID string) string {
if jobID == "" {
return ""
}
return fmt.Sprintf("https://app.servicetrade.com/jobs/%s", jobID)
}
func writeExcelRow(f *excelize.File, sheet string, result rowResult, index int, textStyle, bodyStyle int) error {
excelRow := index + 2
values := []string{
result.customerPO,
result.jobID,
result.link,
result.clockIn,
result.clockOut,
}
for colIdx, value := range values {
cell, _ := excelize.CoordinatesToCellName(colIdx+1, excelRow)
style := bodyStyle
if colIdx == 1 {
style = textStyle
}
if err := f.SetCellStr(sheet, cell, value); err != nil {
return err
}
if err := f.SetCellStyle(sheet, cell, cell, style); err != nil {
return err
}
if colIdx == 2 && value != "" {
if err := f.SetCellHyperLink(sheet, cell, value, "External"); err != nil {
log.Printf("Warning: unable to set hyperlink for cell %s: %v", cell, err)
}
}
}
return nil
}
return ""
func fetchInvoice(session *api.Session, identifier string, caches *sharedCaches, limiter *rateLimiter) (map[string]interface{}, error) {
if identifier == "" {
return nil, nil
}
if invoice, ok := caches.getInvoice(identifier); ok {
return invoice, nil
}
if limiter != nil {
limiter.Wait()
}
invoice, err := session.GetInvoice(identifier)
if err != nil {
return nil, err
}
if invoice != nil {
caches.setInvoice(identifier, invoice)
}
return invoice, nil
}
func determineAssignmentType(techNames []string, vendorName string) string {
if len(techNames) > 0 {
return "Technician"
func extractJobIDFromInvoice(invoice map[string]interface{}) string {
if invoice == nil {
return ""
}
if vendorName != "" {
return "Vendor"
jobInfo, ok := invoice["job"].(map[string]interface{})
if !ok {
return ""
}
return "Unknown"
return cleanIdentifier(valueToString(jobInfo["id"]))
}
func formatActivityEvents(events []api.ClockPairedEvent, activity string) string {
var parts []string
activityLower := strings.ToLower(activity)
type clockSummary struct {
ClockIn string
ClockOut string
}
for _, e := range events {
if strings.ToLower(e.Start.Activity) != activityLower {
continue
}
func fetchClockSummary(session *api.Session, jobID string, caches *sharedCaches, limiter *rateLimiter) (clockSummary, error) {
if jobID == "" {
return clockSummary{}, nil
}
if summary, ok := caches.getClock(jobID); ok {
return summary, nil
}
tech := strings.TrimSpace(e.Start.User.Name)
if tech == "" {
tech = "Unknown Tech"
}
if limiter != nil {
limiter.Wait()
}
data, err := session.GetJobClockEvents(jobID)
if err != nil {
return clockSummary{}, err
}
start := formatTimestamp(e.Start.EventTime)
end := formatTimestamp(e.End.EventTime)
var durationText string
if e.ElapsedTime > 0 {
duration := time.Duration(e.ElapsedTime) * time.Second
durationText = duration.String()
}
var earliestStart int64
var latestEnd int64
detail := fmt.Sprintf("%s: %s -> %s", tech, start, end)
if durationText != "" {
detail = fmt.Sprintf("%s (%s)", detail, durationText)
for _, event := range data.PairedEvents {
if event.Start.EventTime != 0 {
if earliestStart == 0 || event.Start.EventTime < earliestStart {
earliestStart = event.Start.EventTime
}
}
if event.End.EventTime != 0 {
if event.End.EventTime > latestEnd {
latestEnd = event.End.EventTime
}
}
parts = append(parts, detail)
}
return strings.Join(parts, " | ")
summary := clockSummary{
ClockIn: formatTimestampPlain(earliestStart),
ClockOut: formatTimestampPlain(latestEnd),
}
caches.setClock(jobID, summary)
return summary, nil
}
func formatTimestamp(epoch int64) string {
func formatTimestampPlain(epoch int64) string {
if epoch == 0 {
return ""
}
return time.Unix(epoch, 0).UTC().Format(time.RFC3339)
local := time.Unix(epoch, 0).In(time.Local)
return local.Format("2006-01-02 15:04:05 MST")
}
func valueToString(value interface{}) string {

862
internal/handlers/web/users.go

@ -1,18 +1,872 @@
package web
import (
root "marmic/servicetrade-toolbox"
"bytes"
"encoding/csv"
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
)
// RoleAssignmentResult captures the outcome of assigning a role to a user.
type RoleAssignmentResult struct {
Token string
RoleID int
Role string
Success bool
Message string
}
// UserImportResult represents the processing result for a single CSV row.
type UserImportResult struct {
Row int
Username string
Email string
UserID int
Created bool
Error string
RoleAssignments []RoleAssignmentResult
ProcessingTime time.Duration
ServiceLineIDs []int
AdditionalFields map[string]string
}
// UserImportSummary aggregates statistics about a CSV import run.
type UserImportSummary struct {
TotalRows int
UsersCreated int
RowsFailed int
RoleAssignments int
RoleAssignmentErrors int
ProcessedFilename string
ProcessedAt time.Time
}
// UserUpdateResult represents the processing result for a single update row.
type UserUpdateResult struct {
Row int
Username string
Email string
UserID int
Updated bool
Error string
UpdatedFields []string
LookupMethod string
RoleAssignments []RoleAssignmentResult
ProcessingTime time.Duration
}
// UserUpdateSummary aggregates statistics about a CSV update run.
type UserUpdateSummary struct {
TotalRows int
UsersUpdated int
RowsFailed int
RoleAssignments int
RoleAssignmentErrors int
LookupsByUsername int
LookupsByID int
ProcessedFilename string
ProcessedAt time.Time
}
// UsersHandler renders the user management page.
func UsersHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := baseUsersPageData(r)
data["Title"] = "Users"
data["Session"] = session
if roles, err := session.ListRoles(); err != nil {
log.Printf("UsersHandler: unable to load roles: %v", err)
data["RolesError"] = err.Error()
} else {
data["Roles"] = roles
}
renderUsersPage(w, r, data)
}
// UsersUploadHandler processes uploaded CSV files to create users and assign roles.
func UsersUploadHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("csvFile")
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
return
}
data := baseUsersPageData(r)
data["Title"] = "Users"
data["Session"] = session
if len(rows) < 2 {
data["FlashError"] = "CSV must include a header row and at least one data row."
renderUsersPage(w, r, data)
return
}
headerMap := buildHeaderIndex(rows[0])
required := []string{"username", "firstname", "lastname", "email", "password", "companyid", "locationid"}
if missing := missingHeaders(headerMap, required); len(missing) > 0 {
data["FlashError"] = fmt.Sprintf("Missing required column headers: %s", strings.Join(missing, ", "))
renderUsersPage(w, r, data)
return
}
roles, rolesErr := session.ListRoles()
if rolesErr != nil {
log.Printf("UsersUploadHandler: unable to load roles: %v", rolesErr)
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
} else {
data["Roles"] = roles
}
roleIndexByName := buildRoleNameIndex(roles)
results := make([]UserImportResult, 0, len(rows)-1)
summary := UserImportSummary{
TotalRows: len(rows) - 1,
ProcessedAt: time.Now(),
ProcessedFilename: header.Filename,
}
for rowIdx, row := range rows[1:] {
rowStart := time.Now()
result := UserImportResult{
Row: rowIdx + 2,
Username: getValue(row, headerMap, "username"),
Email: getValue(row, headerMap, "email"),
AdditionalFields: map[string]string{},
}
if rowIsEmpty(row) {
continue
}
if err := validateRequiredRowFields(row, headerMap, required); err != nil {
result.Error = err.Error()
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
companyID, err := strconv.Atoi(getValue(row, headerMap, "companyid"))
if err != nil {
result.Error = fmt.Sprintf("invalid companyId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
locationID, err := strconv.Atoi(getValue(row, headerMap, "locationid"))
if err != nil {
result.Error = fmt.Sprintf("invalid locationId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
isSales, err := parseOptionalBool(getValue(row, headerMap, "issales"))
if err != nil {
result.Error = fmt.Sprintf("invalid isSales value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired"))
if err != nil {
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
managerID, err := parseOptionalInt(getValue(row, headerMap, "managerid"))
if err != nil {
result.Error = fmt.Sprintf("invalid managerId value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids"))
if err != nil {
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.ServiceLineIDs = serviceLineIDs
payload := api.CreateUserPayload{
Username: result.Username,
FirstName: getValue(row, headerMap, "firstname"),
LastName: getValue(row, headerMap, "lastname"),
Password: getValue(row, headerMap, "password"),
Email: result.Email,
Phone: getValue(row, headerMap, "phone"),
CompanyID: companyID,
LocationID: locationID,
Details: getValue(row, headerMap, "details"),
Status: getValue(row, headerMap, "status"),
Timezone: getValue(row, headerMap, "timezone"),
ServiceLineIDs: serviceLineIDs,
}
if isSales != nil {
payload.IsSales = isSales
}
if mfaRequired != nil {
payload.MFARequired = mfaRequired
}
if managerID != nil {
payload.ManagerID = managerID
}
userRecord, err := session.CreateUser(payload)
if err != nil {
result.Error = fmt.Sprintf("failed to create user: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.Created = true
result.UserID = userRecord.ID
summary.UsersCreated++
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
if len(roleTokens) > 0 {
assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
for _, token := range roleTokens {
assignResult := RoleAssignmentResult{Token: token}
role, resolved, message := resolveRoleToken(token, roleIndexByName)
if !resolved {
assignResult.Message = message
assignments = append(assignments, assignResult)
summary.RoleAssignmentErrors++
continue
}
assignResult.RoleID = role.ID
assignResult.Role = role.Name
if err := session.AssignRoleToUser(userRecord.ID, role.ID); err != nil {
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
summary.RoleAssignmentErrors++
} else {
assignResult.Success = true
assignResult.Message = "assigned"
summary.RoleAssignments++
}
assignments = append(assignments, assignResult)
}
result.RoleAssignments = assignments
}
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
}
data["ImportResults"] = results
data["ImportSummary"] = summary
data["FlashSuccess"] = fmt.Sprintf("Processed %d row(s) in %s.", summary.TotalRows, time.Since(start).Round(time.Millisecond))
renderUsersPage(w, r, data)
}
// UsersUpdateHandler renders the user update page.
func UsersUpdateHandler(w http.ResponseWriter, r *http.Request) {
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
data := baseUsersPageData(r)
data["Title"] = "User Updates"
data["Session"] = session
if roles, err := session.ListRoles(); err != nil {
log.Printf("UsersUpdateHandler: unable to load roles: %v", err)
data["RolesError"] = err.Error()
} else {
data["Roles"] = roles
}
renderUsersUpdatePage(w, r, data)
}
// UsersUpdateUploadHandler processes CSV uploads to update existing users.
func UsersUpdateUploadHandler(w http.ResponseWriter, r *http.Request) {
start := time.Now()
session, ok := r.Context().Value(middleware.SessionKey).(*api.Session)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, fmt.Sprintf("Unable to parse upload: %v", err), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("csvFile")
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read file: %v", err), http.StatusBadRequest)
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
http.Error(w, fmt.Sprintf("Unable to read CSV: %v", err), http.StatusBadRequest)
return
}
data := baseUsersPageData(r)
data["Title"] = "User Updates"
data["Session"] = session
if len(rows) < 2 {
data["FlashError"] = "CSV must include a header row and at least one data row."
renderUsersUpdatePage(w, r, data)
return
}
headerMap := buildHeaderIndex(rows[0])
if _, ok := headerMap["username"]; !ok {
if _, ok := headerMap["userid"]; !ok {
data["FlashError"] = "CSV must include either a username or userId column to locate users."
renderUsersUpdatePage(w, r, data)
return
}
}
roles, rolesErr := session.ListRoles()
if rolesErr != nil {
log.Printf("UsersUpdateUploadHandler: unable to load roles: %v", rolesErr)
data["RolesError"] = fmt.Sprintf("Unable to load roles: %v. Role names in the CSV will not resolve.", rolesErr)
} else {
data["Roles"] = roles
}
roleIndexByName := buildRoleNameIndex(roles)
results := make([]UserUpdateResult, 0, len(rows)-1)
summary := UserUpdateSummary{
TotalRows: len(rows) - 1,
ProcessedAt: time.Now(),
ProcessedFilename: header.Filename,
}
for rowIdx, row := range rows[1:] {
rowStart := time.Now()
if rowIsEmpty(row) {
continue
}
result := UserUpdateResult{
Row: rowIdx + 2,
Username: getValue(row, headerMap, "username"),
Email: getValue(row, headerMap, "email"),
}
userIDValue := getValue(row, headerMap, "userid")
var userID int
if userIDValue != "" {
parsedID, err := strconv.Atoi(userIDValue)
if err != nil || parsedID <= 0 {
result.Error = fmt.Sprintf("invalid userId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
userID = parsedID
result.UserID = userID
result.LookupMethod = "userId"
summary.LookupsByID++
} else if result.Username != "" {
userRecord, err := session.FindUserByUsername(result.Username)
if err != nil {
result.Error = fmt.Sprintf("failed to locate user by username: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
userID = userRecord.ID
result.UserID = userID
result.LookupMethod = "username"
summary.LookupsByUsername++
} else {
result.Error = "row missing username and userId"
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload := api.UpdateUserPayload{}
var updatedFields []string
if v := getValue(row, headerMap, "firstname"); v != "" {
payload.FirstName = stringPtr(v)
updatedFields = append(updatedFields, "firstName")
}
if v := getValue(row, headerMap, "new_username"); v != "" {
payload.Username = stringPtr(v)
updatedFields = append(updatedFields, "username")
}
if v := getValue(row, headerMap, "lastname"); v != "" {
payload.LastName = stringPtr(v)
updatedFields = append(updatedFields, "lastName")
}
if v := getValue(row, headerMap, "email"); v != "" {
payload.Email = stringPtr(v)
updatedFields = append(updatedFields, "email")
}
if v := getValue(row, headerMap, "password"); v != "" {
payload.Password = stringPtr(v)
updatedFields = append(updatedFields, "password")
}
if v := getValue(row, headerMap, "phone"); v != "" {
payload.Phone = stringPtr(v)
updatedFields = append(updatedFields, "phone")
}
if v := getValue(row, headerMap, "details"); v != "" {
payload.Details = stringPtr(v)
updatedFields = append(updatedFields, "details")
}
if v := getValue(row, headerMap, "status"); v != "" {
payload.Status = stringPtr(v)
updatedFields = append(updatedFields, "status")
}
if v := getValue(row, headerMap, "timezone"); v != "" {
payload.Timezone = stringPtr(v)
updatedFields = append(updatedFields, "timezone")
}
if companyVal := getValue(row, headerMap, "companyid"); companyVal != "" {
companyID, err := strconv.Atoi(companyVal)
if err != nil {
result.Error = fmt.Sprintf("invalid companyId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.CompanyID = intPtr(companyID)
updatedFields = append(updatedFields, "companyId")
}
if locationVal := getValue(row, headerMap, "locationid"); locationVal != "" {
locationID, err := strconv.Atoi(locationVal)
if err != nil {
result.Error = fmt.Sprintf("invalid locationId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.LocationID = intPtr(locationID)
updatedFields = append(updatedFields, "locationId")
}
if managerVal := getValue(row, headerMap, "managerid"); managerVal != "" {
managerID, err := strconv.Atoi(managerVal)
if err != nil {
result.Error = fmt.Sprintf("invalid managerId: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
payload.ManagerID = intPtr(managerID)
updatedFields = append(updatedFields, "managerId")
}
if isSales, err := parseOptionalBool(getValue(row, headerMap, "issales")); err != nil {
result.Error = fmt.Sprintf("invalid isSales value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if isSales != nil {
payload.IsSales = isSales
updatedFields = append(updatedFields, "isSales")
}
if mfaRequired, err := parseOptionalBool(getValue(row, headerMap, "mfarequired")); err != nil {
result.Error = fmt.Sprintf("invalid mfaRequired value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if mfaRequired != nil {
payload.MFARequired = mfaRequired
updatedFields = append(updatedFields, "mfaRequired")
}
if serviceLineIDs, err := parseIntList(getValue(row, headerMap, "servicelineids")); err != nil {
result.Error = fmt.Sprintf("invalid serviceLineIds value: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
} else if serviceLineIDs != nil {
payload.ServiceLineIDs = &serviceLineIDs
updatedFields = append(updatedFields, "serviceLineIds")
}
if len(updatedFields) == 0 {
result.Error = "no updatable fields provided in row"
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
if _, err := session.UpdateUser(userID, payload); err != nil {
result.Error = fmt.Sprintf("failed to update user: %v", err)
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
summary.RowsFailed++
continue
}
result.Updated = true
result.UpdatedFields = updatedFields
summary.UsersUpdated++
roleTokens := parseRoleTokens(getValue(row, headerMap, "roles"))
if len(roleTokens) > 0 {
assignments := make([]RoleAssignmentResult, 0, len(roleTokens))
for _, token := range roleTokens {
assignResult := RoleAssignmentResult{Token: token}
role, resolved, message := resolveRoleToken(token, roleIndexByName)
if !resolved {
assignResult.Message = message
assignments = append(assignments, assignResult)
summary.RoleAssignmentErrors++
continue
}
assignResult.RoleID = role.ID
assignResult.Role = role.Name
if err := session.AssignRoleToUser(userID, role.ID); err != nil {
assignResult.Message = fmt.Sprintf("failed to assign role: %v", err)
summary.RoleAssignmentErrors++
} else {
assignResult.Success = true
assignResult.Message = "assigned"
summary.RoleAssignments++
}
assignments = append(assignments, assignResult)
}
result.RoleAssignments = assignments
}
result.ProcessingTime = time.Since(rowStart)
results = append(results, result)
}
data["UpdateResults"] = results
data["UpdateSummary"] = summary
if summary.UsersUpdated > 0 {
data["FlashSuccess"] = fmt.Sprintf("Updated %d user(s) (processed %d rows in %s).", summary.UsersUpdated, summary.TotalRows, time.Since(start).Round(time.Millisecond))
} else {
data["FlashError"] = "No users were updated. Review the row errors below."
}
renderUsersUpdatePage(w, r, data)
}
func renderUsersPage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
renderUsersTemplatePage(w, r, data, "users_content")
}
func renderUsersUpdatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}) {
renderUsersTemplatePage(w, r, data, "users_update_content")
}
func renderUsersTemplatePage(w http.ResponseWriter, r *http.Request, data map[string]interface{}, templateName string) {
tmpl := root.WebTemplates
if r.Header.Get("HX-Request") == "true" {
if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil {
log.Printf("UsersHandler: template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
var contentBuf bytes.Buffer
if err := tmpl.ExecuteTemplate(&contentBuf, templateName, data); err != nil {
log.Printf("UsersHandler: template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data["BodyContent"] = template.HTML(contentBuf.String())
if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("UsersHandler: layout template error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func baseUsersPageData(r *http.Request) map[string]interface{} {
data := map[string]interface{}{
"Title": "Users",
"CSRFField": csrf.TemplateField(r),
"CSRFToken": csrf.Token(r),
}
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
data["CSRFCookie"] = c.Value
} else if c, err := r.Cookie("XSRF-TOKEN-VALUE"); err == nil {
data["CSRFCookie"] = c.Value
}
return data
}
func buildHeaderIndex(headers []string) map[string]int {
index := make(map[string]int, len(headers))
for idx, header := range headers {
if header == "" {
continue
}
normalized := normalizeHeader(header)
if canonical, ok := headerCanonicalMap[normalized]; ok {
index[canonical] = idx
} else {
index[normalized] = idx
}
}
return index
}
err := tmpl.Execute(w, data)
func missingHeaders(headerIndex map[string]int, required []string) []string {
var missing []string
for _, key := range required {
if _, ok := headerIndex[key]; !ok {
missing = append(missing, key)
}
}
return missing
}
func rowIsEmpty(row []string) bool {
for _, value := range row {
if strings.TrimSpace(value) != "" {
return false
}
}
return true
}
func validateRequiredRowFields(row []string, index map[string]int, required []string) error {
var missing []string
for _, key := range required {
if strings.TrimSpace(getValue(row, index, key)) == "" {
missing = append(missing, key)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required value(s): %s", strings.Join(missing, ", "))
}
return nil
}
func getValue(row []string, index map[string]int, key string) string {
if idx, ok := index[key]; ok && idx < len(row) {
return strings.TrimSpace(row[idx])
}
return ""
}
func parseOptionalBool(value string) (*bool, error) {
if value == "" {
return nil, nil
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "true", "1", "yes", "y":
result := true
return &result, nil
case "false", "0", "no", "n":
result := false
return &result, nil
default:
return nil, fmt.Errorf("expected boolean value, got %q", value)
}
}
func parseOptionalInt(value string) (*int, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
intVal, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return nil, err
}
return &intVal, nil
}
func parseIntList(raw string) ([]int, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
parts := splitTokens(raw)
ints := make([]int, 0, len(parts))
for _, part := range parts {
id, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", part)
}
ints = append(ints, id)
}
return ints, nil
}
func parseRoleTokens(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
return splitTokens(raw)
}
func splitTokens(raw string) []string {
cleaned := strings.NewReplacer(";", ",", "|", ",").Replace(raw)
parts := strings.Split(cleaned, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
token := strings.TrimSpace(part)
if token != "" {
result = append(result, token)
}
}
return result
}
func resolveRoleToken(token string, nameIndex map[string]api.Role) (api.Role, bool, string) {
clean := strings.TrimSpace(token)
if clean == "" {
return api.Role{}, false, "empty role token"
}
normalized := normalizeRoleName(clean)
if role, ok := nameIndex[normalized]; ok {
return role, true, ""
}
return api.Role{}, false, fmt.Sprintf("role %q not found; use the role name exactly as listed", token)
}
func buildRoleNameIndex(roles []api.Role) map[string]api.Role {
nameIndex := make(map[string]api.Role, len(roles))
for _, role := range roles {
nameKey := normalizeRoleName(role.Name)
if nameKey != "" {
nameIndex[nameKey] = role
}
}
return nameIndex
}
func normalizeHeader(value string) string {
v := strings.TrimSpace(strings.ToLower(value))
v = strings.ReplaceAll(v, " ", "")
v = strings.ReplaceAll(v, "_", "")
v = strings.ReplaceAll(v, "-", "")
return v
}
func normalizeRoleName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
var headerCanonicalMap = map[string]string{
"username": "username",
"user": "username",
"firstname": "firstname",
"first": "firstname",
"lastname": "lastname",
"last": "lastname",
"email": "email",
"mail": "email",
"password": "password",
"pass": "password",
"companyid": "companyid",
"company": "companyid",
"locationid": "locationid",
"location": "locationid",
"userid": "userid",
"id": "userid",
"newusername": "new_username",
"username_new": "new_username",
"usernameupdate": "new_username",
"phone": "phone",
"phonenumber": "phone",
"roles": "roles",
"roleids": "roles",
"rolenames": "roles",
"status": "status",
"timezone": "timezone",
"details": "details",
"issales": "issales",
"sales": "issales",
"managerid": "managerid",
"manager": "managerid",
"mfarequired": "mfarequired",
"mfa": "mfarequired",
"servicelineids": "servicelineids",
}
func stringPtr(value string) *string {
v := value
return &v
}
func intPtr(value int) *int {
v := value
return &v
}

36
internal/middleware/csrf_simple.go

@ -20,16 +20,26 @@ func CSRFSimple(next http.Handler) http.Handler {
// Ensure token cookie exists for safe methods
if method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions || method == http.MethodTrace {
if _, err := r.Cookie("XSRF-TOKEN"); err != nil {
// Generate a random token
token := ""
if c, err := r.Cookie("XSRF-TOKEN"); err == nil {
if v := strings.TrimSpace(c.Value); v != "" && !strings.Contains(v, "|") {
token = v
}
}
if token == "" {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
token := base64.RawURLEncoding.EncodeToString(buf)
token = base64.RawURLEncoding.EncodeToString(buf)
}
}
if token != "" {
// Refresh both legacy and simple-mode cookie names so the frontend reads the correct value.
for _, name := range []string{"XSRF-TOKEN", "XSRF-TOKEN-VALUE"} {
http.SetCookie(w, &http.Cookie{
Name: "XSRF-TOKEN",
Name: name,
Value: token,
Path: "/",
HttpOnly: false, // must be readable by client script
HttpOnly: false,
Secure: isHTTPS,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(12 * time.Hour),
@ -53,16 +63,24 @@ func CSRFSimple(next http.Handler) http.Handler {
}
}
cookie, err := r.Cookie("XSRF-TOKEN")
if err != nil || token == "" || cookie == nil || cookie.Value == "" || cookie.Value != token {
tokenCookie, err := r.Cookie("XSRF-TOKEN")
if err != nil || tokenCookie == nil || strings.Contains(tokenCookie.Value, "|") || tokenCookie.Value == "" {
if legacyCookie, legacyErr := r.Cookie("XSRF-TOKEN-VALUE"); legacyErr == nil {
tokenCookie = legacyCookie
} else {
err = legacyErr
}
}
if token == "" || tokenCookie == nil || tokenCookie.Value == "" || tokenCookie.Value != token {
if token == "" {
if headerToken := r.Header.Get("X-CSRF-Token"); headerToken != "" {
token = headerToken
}
}
log.Printf("CSRF validation failed (simple mode): header=%q form=%q cookie=%q err=%v", r.Header.Get("X-CSRF-Token"), token, func() string {
if cookie != nil {
return cookie.Value
if tokenCookie != nil {
return tokenCookie.Value
}
return ""
}(), err)

31
templates/generic.html

@ -11,12 +11,14 @@
<section class="card">
<h4>Invoice Clock Events Report (Proof of Concept)</h4>
<p>
Upload a CSV containing invoice numbers to generate a downloadable report that aggregates
related job information, assigned technicians, and enroute/onsite clock events.
Upload a CSV export that contains either job ids or invoice numbers. The tool automatically detects
which identifier is usable, gathers the related clock activity, and returns an Excel workbook formatted
like the shared audit template (Customer PO, id, Link, Clock In, Clock Out) with the header row frozen
and columns expanded.
</p>
<form id="invoice-clock-form" class="form" action="/reports/invoice-clock" method="POST"
enctype="multipart/form-data">
enctype="multipart/form-data" hx-target="#invoice-clock-status" hx-indicator=".htmx-indicator">
{{if .CSRFField}}
{{.CSRFField}}
{{end}}
@ -26,12 +28,22 @@
<input class="card-input" type="file" id="invoiceCsv" name="invoiceCsv" accept=".csv" required>
</div>
<p class="help-text">
The CSV may include a header named <code>invoice_number</code>; if no header is present, the first
column
is used. Each invoice number is processed individually.
If a <code>job id</code> column is present we use it directly; otherwise we fall back to columns such as
<code>invoice_number</code> or <code>customer po</code> to discover the job before pulling clock events.
</p>
<button class="btn-primary" type="submit">Generate Report</button>
<button class="btn-primary" type="submit">Generate XLSX Report</button>
</form>
<div class="status-message subtle-tip">
Generating very large reports can take a few minutes after you submit. Please keep the tab open while the download prepares.
</div>
<div id="invoice-clock-indicator" class="htmx-indicator status-indicator" style="display:none;">
<div class="spinner"></div>
<div>
<strong>Building report…</strong>
<p>Hang tight while we fetch clock events and assemble the workbook.</p>
</div>
</div>
<div id="invoice-clock-status" class="status-message"></div>
</section>
</div>
</div>
@ -69,11 +81,6 @@
document.addEventListener('submit', ensureTokenBeforeSubmit, true);
if (document.body && document.body.addEventListener) {
document.body.addEventListener('htmx:load', function (evt) {
applyToken(evt.target || document);
});
}
})();
</script>
{{end}}

3
templates/layout.html

@ -45,6 +45,7 @@
<li><a href="/services">Services</a></li>
<li><a href="/tags">Tags</a></li>
<li><a href="/users">Users</a></li>
<li><a href="/users/update">User Updates</a></li>
<li><a href="/admin">Admin</a></li>
</ul>
</nav>
@ -87,6 +88,8 @@
{{template "tags_content" .}}
{{else if eq .Title "Users"}}
{{template "users_content" .}}
{{else if eq .Title "User Updates"}}
{{template "users_update_content" .}}
{{else if eq .Title "Admin"}}
{{template "admin_content" .}}
{{else}}

132
templates/users.html

@ -1,18 +1,132 @@
{{define "users_content"}}
<div class="page-header">
<h2>Users Management</h2>
<p>Manage user accounts and permissions.</p>
<h2>User Imports</h2>
<p>Upload a CSV to create ServiceTrade users and assign roles in bulk.</p>
</div>
<div class="page-content">
<div class="content">
<h3 class="submenu-header">User Search & Management</h3>
<p>User management functionality will be implemented here.</p>
<div class="placeholder-content">
<div class="placeholder-icon">👤</div>
<h4>Coming Soon</h4>
<p>User management features are under development.</p>
<h3 class="submenu-header">Upload CSV</h3>
{{if .FlashError}}
<div class="error-message">{{.FlashError}}</div>
{{end}}
{{if .FlashSuccess}}
<div class="info-message">{{.FlashSuccess}}</div>
{{end}}
<form action="/users/upload" method="POST" enctype="multipart/form-data">
{{if .CSRFField}}{{.CSRFField}}{{end}}
{{if .CSRFCookie}}<input type="hidden" name="csrfToken" value="{{.CSRFCookie}}">{{end}}
<label for="users-csv">CSV file</label>
<input id="users-csv" name="csvFile" type="file" accept=".csv" class="card-input" required>
<button type="submit" class="btn-primary">Process CSV</button>
</form>
<div class="info-message" style="margin-top: 1rem;">
<strong>Required headers:</strong> username, firstName, lastName, email, password, companyId, locationId.
Optional headers include phone, status, timezone, details, isSales, managerId, mfaRequired, serviceLineIds, and roles.
In the roles column list the human-readable role names (comma, semicolon, or pipe separated); names are matched case-insensitively.
</div>
</div>
{{if .RolesError}}
<div class="content" style="margin-top:1rem;">
<div class="error-message">{{.RolesError}}</div>
</div>
{{end}}
{{if .Roles}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Available Roles</h3>
<p>Use the role names from this list in the CSV roles column.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Global</th>
</tr>
</thead>
<tbody>
{{range .Roles}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{if .Global}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .ImportSummary}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Import Summary</h3>
<p>
Processed file: <strong>{{.ImportSummary.ProcessedFilename}}</strong> at {{.ImportSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
</p>
<ul>
<li>Total rows: {{.ImportSummary.TotalRows}}</li>
<li>Users created: {{.ImportSummary.UsersCreated}}</li>
<li>Rows failed: {{.ImportSummary.RowsFailed}}</li>
<li>Role assignments succeeded: {{.ImportSummary.RoleAssignments}}</li>
<li>Role assignments failed: {{.ImportSummary.RoleAssignmentErrors}}</li>
</ul>
</div>
{{end}}
{{if .ImportResults}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Import Details</h3>
<table>
<thead>
<tr>
<th>Row</th>
<th>Username</th>
<th>Email</th>
<th>User Result</th>
<th>Role Assignments</th>
<th>Processing Time</th>
</tr>
</thead>
<tbody>
{{range .ImportResults}}
<tr>
<td>{{.Row}}</td>
<td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>
{{if .Error}}
<div class="error-message">{{.Error}}</div>
{{else if .Created}}
Created (#{{.UserID}})
{{else}}
Skipped
{{end}}
</td>
<td>
{{if .RoleAssignments}}
<ul>
{{range .RoleAssignments}}
<li>
{{if .Success}}✅{{else}}⚠️{{end}}
{{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} &mdash; {{.Message}}
</li>
{{end}}
</ul>
{{else if .Error}}
&mdash;
{{else}}
No roles provided
{{end}}
</td>
<td>{{.ProcessingTime}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
{{end}}

152
templates/users_update.html

@ -0,0 +1,152 @@
{{define "users_update_content"}}
<div class="page-header">
<h2>User Updates</h2>
<p>Upload a CSV to update existing ServiceTrade users and assign additional roles.</p>
</div>
<div class="page-content">
<div class="content">
<h3 class="submenu-header">Upload Update CSV</h3>
{{if .FlashError}}
<div class="error-message">{{.FlashError}}</div>
{{end}}
{{if .FlashSuccess}}
<div class="info-message">{{.FlashSuccess}}</div>
{{end}}
<form action="/users/update/upload" method="POST" enctype="multipart/form-data">
{{if .CSRFField}}{{.CSRFField}}{{end}}
{{if .CSRFCookie}}<input type="hidden" name="csrfToken" value="{{.CSRFCookie}}">{{end}}
<label for="users-update-csv">CSV file</label>
<input id="users-update-csv" name="csvFile" type="file" accept=".csv" class="card-input" required>
<button type="submit" class="btn-primary">Process Updates</button>
</form>
<div class="info-message" style="margin-top: 1rem;">
Provide either <strong>userId</strong> or <strong>username</strong> for each row. Use <strong>new_username</strong> to change an account's username. All other columns match the create template; only non-empty values are applied. Roles column should list human-readable role names (comma, semicolon, or pipe separated).
</div>
</div>
{{if .RolesError}}
<div class="content" style="margin-top:1rem;">
<div class="error-message">{{.RolesError}}</div>
</div>
{{end}}
{{if .Roles}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Available Roles</h3>
<p>Use the role names from this list in the CSV roles column.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Global</th>
</tr>
</thead>
<tbody>
{{range .Roles}}
<tr>
<td>{{.ID}}</td>
<td>{{.Name}}</td>
<td>{{.Description}}</td>
<td>{{if .Global}}Yes{{else}}No{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{if .UpdateSummary}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Update Summary</h3>
<p>
Processed file: <strong>{{.UpdateSummary.ProcessedFilename}}</strong> at {{.UpdateSummary.ProcessedAt.Format "2006-01-02 15:04 MST"}}.
</p>
<ul>
<li>Total rows: {{.UpdateSummary.TotalRows}}</li>
<li>Users updated: {{.UpdateSummary.UsersUpdated}}</li>
<li>Rows failed: {{.UpdateSummary.RowsFailed}}</li>
<li>Role assignments succeeded: {{.UpdateSummary.RoleAssignments}}</li>
<li>Role assignments failed: {{.UpdateSummary.RoleAssignmentErrors}}</li>
<li>Lookups by username: {{.UpdateSummary.LookupsByUsername}}</li>
<li>Lookups by userId: {{.UpdateSummary.LookupsByID}}</li>
</ul>
</div>
{{end}}
{{if .UpdateResults}}
<div class="content" style="margin-top:1rem;">
<h3 class="submenu-header">Update Details</h3>
<table>
<thead>
<tr>
<th>Row</th>
<th>User</th>
<th>Lookup</th>
<th>Updated Fields</th>
<th>Result</th>
<th>Role Assignments</th>
<th>Processing Time</th>
</tr>
</thead>
<tbody>
{{range .UpdateResults}}
<tr>
<td>{{.Row}}</td>
<td>
{{if .UserID}}
#{{.UserID}}
{{end}}
{{if .Username}}
({{.Username}})
{{end}}
</td>
<td>{{.LookupMethod}}</td>
<td>
{{if .UpdatedFields}}
<ul>
{{range .UpdatedFields}}
<li>{{.}}</li>
{{end}}
</ul>
{{else}}
&mdash;
{{end}}
</td>
<td>
{{if .Error}}
<div class="error-message">{{.Error}}</div>
{{else if .Updated}}
Updated
{{else}}
Skipped
{{end}}
</td>
<td>
{{if .RoleAssignments}}
<ul>
{{range .RoleAssignments}}
<li>
{{if .Success}}✅{{else}}⚠️{{end}}
{{if .Role}}{{.Role}} ({{.RoleID}}){{else}}{{.Token}}{{end}} &mdash; {{.Message}}
</li>
{{end}}
</ul>
{{else if .Error}}
&mdash;
{{else}}
No roles provided
{{end}}
</td>
<td>{{.ProcessingTime}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
Loading…
Cancel
Save