webapp
Some checks failed
Docker Build and Publish / publish (push) Failing after 1m33s

This commit is contained in:
Eugene Howe
2026-02-17 09:47:30 -05:00
parent af09672ee3
commit b0957bfa49
102 changed files with 4213 additions and 378 deletions

44
.air.toml Normal file
View File

@@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -0,0 +1,34 @@
name: Docker Build and Publish
on:
push:
branches: [main]
jobs:
publish:
runs-on: ubuntu-latest
container:
image: ghcr.io/catthehacker/ubuntu:act-latest
steps:
- uses: https://github.com/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
env:
DOCKER_HOST: unix:///var/run/docker.sock
with:
config-inline: |
[registry."docker.office.clintonambulance.com"]
- name: Log in to Docker Registry
uses: https://github.com/docker/login-action@v3
with:
registry: docker.office.clintonambulance.com
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5
env:
DOCKER_HOST: unix:///var/run/docker.sock
with:
context: .
file: ./Dockerfile
push: true
tags: "docker.office.clintonambulance.com/calculate_negative_points:${{gitea.sha}},docker.office.clintonambulance.com/calculate_negative_points:latest"

4
.gitignore vendored
View File

@@ -3,6 +3,6 @@
*.log *.log
build/*.txt build/*.txt
*.txt *.txt
*.xls
/tmp/ /tmp/
inputs/

View File

@@ -1 +1,3 @@
golang 1.25.6 golang 1.25.6
golangci-lint 2.8.0
bun 1.3.8

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
FROM golang:1.25.6 AS gobuilder
WORKDIR /opt/app-root
COPY Makefile .tool-versions go.mod go.sum ./
RUN --mount=type=tmpfs,target=/tmp \
--mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \
--mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \
make mod
COPY . /opt/app-root/
RUN --mount=type=tmpfs,target=/tmp \
--mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \
--mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \
CGO_ENABLED="0" BINDIR="/usr/local/bin" make clean; CGO_ENABLED="0" BINDIR="/usr/local/bin" make build
FROM oven/bun:1.3.8 AS bunbuilder
ENV NODE_ENV="production"
COPY . /opt/app-root
WORKDIR /opt/app-root/frontend
RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/log \
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=calculate-negative-points-bun138-build-share-yarn-cache \
--mount=type=cache,target=/opt/app-root/frontend/node_modules,id=calculate-negative-points-bun138-build-app-node-modules \
bun install; bunx --bun vite build
FROM golang:1.25.6 AS final
WORKDIR $NON_ROOT_USER_HOME
COPY --from=bunbuilder /opt/app-root/public /opt/app-root/public
COPY --from=gobuilder /usr/local/bin/calculate_negative_points /usr/local/bin/calculate_negative_points
COPY --from=gobuilder /opt/app-root/internal/views /opt/app-root/views
COPY --from=gobuilder /opt/app-root/db/migrations /opt/app-root/migrations
COPY config/ /opt/app-root/config
ENV TZ=America/Detroit
EXPOSE 3000
CMD ["/usr/local/bin/calculate_negative_points", "--listen", "0.0.0.0:3000"]

100
Makefile Normal file
View File

@@ -0,0 +1,100 @@
# Ignore built-in rules, these do not apply for Go builds
MAKEFLAGS += --no-builtin-rules
# Ignore the default suffixes, in this automation script all names refer to actual tasks and files
.SUFFIXES:
.PHONY: all
all: check test build
###
### Pick the right Go SDK if several are available
###
GO_SDK_VERSION ?= $(shell \awk '/^golang [0-9]/ { print $$2 }' .tool-versions)
ifneq (,$(shell command -v go$(GO_SDK_VERSION)))
# Use the selected SDK
GOVERSION ?= go$(GO_SDK_VERSION)
else
# No SDK in the PATH, fall back on whatever version is available
GOVERSION := go
endif
ifeq ($(OS),Darwin)
# Use the selected SDK
LINTOS ?= darwin
else
# No SDK in the PATH, fall back on whatever version is available
LINTOS := linux
endif
ifeq ($(shell uname -p),aarch64)
COERCED_ARCH = arm64
else
COERCED_ARCH = amd64
endif
export BUILD_DATE ?= $(shell \date -u '+%FT%TZ')
export GIT_COMMIT ?= $(shell git describe --match=NeVeRmAtCh --always --abbrev=7 --dirty='*')
export APP_MAJOR_MINOR_VERSION ?= $(shell \grep -m1 -o -E '^[0-9]+\.[0-9]+' VERSION)
export APP_PATCH_VERSION ?= dev
export APP_VERSION ?= $(APP_MAJOR_MINOR_VERSION).$(APP_PATCH_VERSION)
CONFIG_PACKAGE := clintonambulance.com/calculate_negative_points/internal/config
LDFLAGS_VERSION := -X $(CONFIG_PACKAGE).release=$(APP_VERSION) -X $(CONFIG_PACKAGE).date=$(BUILD_DATE) -X $(CONFIG_PACKAGE).commit=$(GIT_COMMIT)
LDFLAGS := -ldflags "$(LDFLAGS_VERSION) $(LDFLAGS_EXTRA)"
###
### Output directory
###
BINDIR ?= $(shell pwd)/bin
$(BINDIR):
install -d $(BINDIR)
.PHONY: mod
mod:
@printf "\nRunning go mod...\n"
$(GOVERSION) mod verify
$(GOVERSION) mod download
@printf "Complete.\n"
.PHONY: check
check: GOFMTCHECK := $(shell gofmt -s -d .)
check:
@printf "\nRunning checks...\n"
@echo - gofmt
@[[ "$(GOFMTCHECK)" == "" ]] || (echo "FAILED: gofmt failed" ; exit 1)
@echo - golangci-lint
@golangci-lint run
@printf "All checks PASSED.\n"
.PHONY: test
test:
@printf "\nRunning tests...\n"
ginkgo -r
@printf "Tests PASSED.\n"
test-until-fail:
@printf "\nRunning tests until something fails...\n"
ginkgo -r --until-it-fails
.PHONY: clean
clean: $(BINDIR)
@printf "\nCleaning output files...\n"
rm -f $(BINDIR)/calculate_negative_points $(BINDIR)/sync_from_samsara
.PHONY: buildbins
buildbins: $(BINDIR)
@printf "\nRunning build...\n"
$(GOVERSION) build $(LDFLAGS) -trimpath -o $(BINDIR)/calculate_negative_points
@printf "Build complete.\n"
.PHONY: build
build: clean buildbins
docs/openapi/api.yaml: $(shell find . -name '*.go')
@printf "\nBuilding OpenAPI document...\n"
$(GOVERSION) run $(LDFLAGS) cmd/generate-api-docs/main.go -e testEnvironment -c internal/config/testdata/settings.test.yml
@printf "\nGenerating API types...\n"
cd frontend && bun run generate-api-types

58
cmd/app.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"net/http"
"os"
"strings"
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
"clintonambulance.com/calculate_negative_points/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
func Execute(version config.Version) {
logger, flushLogs := config.NewLogger(version, os.Stdout, os.Args)
defer flushLogs()
configuration := utils.Must(config.Load(version, os.Exit, os.Args, logger))(logger)
logger.Info("Application startup", zap.Any("config", configuration), zap.Any("full_version", version))
srv, _ := server.NewHttpServer(logger, version)
FileServer(srv, "/assets/", configuration.PublicPath, logger)
server.MountAllEndpoints(srv, version, configuration, logger)
srv.NotFound(func(w http.ResponseWriter, _ *http.Request) {
apimiddleware.ErrorResponder(w, "Not Found", http.StatusNotFound)
})
srv.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
apimiddleware.ErrorResponder(w, "Method Not Allowed", http.StatusMethodNotAllowed)
})
if err := http.ListenAndServe(configuration.Listen, srv); err != nil {
logger.Fatal("HTTP server error", zap.Error(err))
}
}
func FileServer(r *web.Service, path string, rootPath string, logger *zap.Logger) {
root := http.Dir(rootPath)
if strings.ContainsAny(path, "{}*") {
panic("FileServer does not permit any URL parameters.")
}
path += "*"
r.Wrapper.Get(path, func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
logger.Info("Serving file", zap.String("root_path", rootPath), zap.String("pathPrefix", pathPrefix), zap.String("rawPath", r.URL.Path))
fs := http.FileServer(root)
fs.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,35 @@
package main
import (
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
"go.uber.org/zap"
)
func main() {
version := config.NewVersion()
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment", "-c", "internal/config/testdata/settings.test.yml"})
defer flushLogs()
configuration, err := config.Load(version, os.Exit, os.Args, logger)
if err != nil {
panic(err)
}
srv, reflector := server.NewHttpServer(logger, version)
server.MountAllEndpoints(srv, version, configuration, logger)
//schema, err := srv.OpenAPI.MarshalYAML()
schema, err := reflector.Spec.MarshalYAML()
if err != nil {
panic(err)
}
outputFile := "docs/openapi/api.yaml"
if err := os.WriteFile(outputFile, schema, 0644); err != nil {
panic(err)
}
logger.Info("Generated OpenAPI docs", zap.String("output", outputFile))
}

1
config/credentials/dev.enc Symbolic link
View File

@@ -0,0 +1 @@
development.enc

1
config/credentials/dev.key Symbolic link
View File

@@ -0,0 +1 @@
development.key

View File

@@ -0,0 +1 @@
43632cedb614ccfcd5fc7362289eb48103bb5d36e9293299bef71362f2ac0b4ae185b9911e0d9d8c50ade436c7f688991719a1cd6b4339aaf91a9ad3c5646b15e5f4f59f127bf9f038623af497d703dce6e38576bda2f41df57c66845bca1cd84430cbdad6d8c2c55564cd01926dbb368bb6328047e1269626478563783add3134618c10fba4735639bc5d0f517192766a843ef04288ea9f7a4f20a2df2f24eaad562f7762b28546e9ecc9fcb462db8eedc2dcc9981952a1a6b9d4654eb0ced0dd5b662904647b5e64163a40289bb780db5333e0154319d0cb67b2aec127bfc1e570b34410078ceb605cc7fb668cd082e326d68d7eb1177ad50058e0053eeefd14c6a59809d5934241e9cee8392b5951f666ff416088731b1ba88bb648dd48e87ddd7585c3a5e4e5e46431c9b0137a8ac2bdb0512fa4ecfc1be3bc59d80682bf6d85b872f2c32740e5cff94614b067f09d9a987d0dcf4b0478d6286b9da1084af7ed6d1dc428047ac4580c216a363c1e10d9a11cb5068f42d5bbd63014ceb83009228a4d41796ceb1432298f5b273dbca63e2f5a0f7d3572b1374cdf85d28a71706aa553d1a2b31aabbcad271c6be6d7d9d3b4cc0693314e08c8789678c6f086984f4f9d3cc7a86c5020a35498944693b7c6d1929b40e4b962f71b051b2f732657d3e8544a789094c214482ae8c823612f17e450e3c463c52335ffa900f0ab7a8cc0abd9c38fc5d9c997b747==--==66d2a6c19fe7d7a40ff1417d

View File

@@ -0,0 +1 @@
f7e00100b7db4e78aaf5c622271476dc31e47478c49c346d7d9e9ed2a9ee0289

View File

@@ -0,0 +1 @@
development.enc

View File

@@ -0,0 +1 @@
development.key

View File

@@ -0,0 +1 @@
9f545b6e36205feadbbc6b788963c4efa3fb2179c9432ca4a14c40b4f2a06b2b0a086cbd987db98e55243f56158702e8974a7e1323c0694c2b572e77f468112dc675057d11b041f042cf310a2548c8f22b927f8b3da42e7ba326f15f4bd0a69b6541192acd09b79d47effbfad3c0639d067258684743e727342bbe78a036292b56436a50513b6a630b6e8321d6862bbd16e015e457ff8b28d1a44de2aca2f9cbc42ba0b19bfbe56106860222fc5cd58e0ab8e42d==--==cd1cef336de7155de2fbd0fc

View File

@@ -0,0 +1 @@
58c2f85a0c5b7ee2a9d5b96773d06700dd0c40a0f6a09f5e33fa5c0ce85a4bf0

View File

@@ -0,0 +1,13 @@
database:
username: "eugene"
ssl_mode: "disable"
app_secret: "0a7b29dd092dd09ac0ea30c5ba59baaf"
app_secret_block: "a55152f70362669c6ea18f17f593ff3b"
path_prefix: "."
paths:
credentials: "config/credentials"
migrations: "db/migrations"
public: "public"
views: "internal/views"
oidc:
redirect_url: "http://localhost:3000"

26
config/settings.yml Normal file
View File

@@ -0,0 +1,26 @@
app_secret: "override_me"
app_secret_block: "override_me"
path_prefix: "/opt/app-root"
paths:
credentials: "config/credentials"
migrations: "migrations"
public: "public"
views: "views"
idms:
base_url: "https://identity.office.clintonambulance.com"
cars_group_id: "01175d60-1a2c-4255-8976-32e6cb205341"
match_threshold: 3
nocodb:
base_url: https://sheets.office.clintonambulance.com
employees_table_id: "m12l1ptaalz9ciq"
infractions_table_id: "myr46d3wdd4hhyg"
negative_infraction_id: 9
no_points_view_id: "vwjtzv5npqodcy71"
oidc:
issuer: "https://identity.office.clintonambulance.com/application/o/calculate-negative-points/"
redirect_url: "https://calculatenegativepoints.office.clintonambulance.com"
scopes:
- openid
- profile
- email
- offline_access

102
docs/openapi/api.yaml Normal file
View File

@@ -0,0 +1,102 @@
openapi: 3.1.0
info:
description: Easily maintain Calculate Negative Points
title: Calculate Negative Points
version: v1.0.0
paths:
/api/process:
post:
description: Process Negative Points
operationId: requests/negative_points_processor.NegativePointsProcessor
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/FormDataNegativePointsProcessorNegativePointsProcessorInput'
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/NegativePointsProcessorNegativePointsProcessorOutput'
description: OK
summary: Negative Points Processor
tags:
- Negative Points Processor
/api/users/current:
delete:
description: Logout current user
operationId: requests/users.Logout
responses:
"200":
content:
application/json:
schema:
additionalProperties: {}
type:
- "null"
- object
description: OK
summary: Logout
tags:
- Users
get:
description: Retrieve the current user
operationId: requests/users.GetCurrentUser
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser'
description: OK
summary: Get Current User
tags:
- Users
components:
schemas:
FormDataNegativePointsProcessorNegativePointsProcessorInput:
properties:
file:
$ref: '#/components/schemas/MultipartFileHeader'
description: XLS schedule file to process
type: object
MultipartFileHeader:
contentMediaType: application/octet-stream
format: binary
type: string
NegativePointsProcessorNegativePointsProcessorOutput:
properties:
employees:
description: List of employees who had negative points
items:
type: string
type: array
required:
- employees
type: object
TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser:
properties:
item:
$ref: '#/components/schemas/TypesUiUser'
required:
- item
type: object
TypesUiUser:
properties:
first_name:
type: string
groups:
items:
type: string
type: array
id:
type: string
last_name:
type: string
name:
type: string
required:
- id
- name
type: object

View File

@@ -1,369 +0,0 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"unicode"
"github.com/samber/lo"
)
const EugeneId = 23
const NegativeInfractionId = 9
const BaseUrl = "https://sheets.office.clintonambulance.com"
const EmployeesTableId = "m12l1ptaalz9ciq"
const NoPointsViewId = "vwjtzv5npqodcy71"
const InfractionsTableId = "myr46d3wdd4hhyg"
// NocoDBRecord represents a record from the NocoDB API
type NocoDBRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
}
// NocoDBResponse represents the API response structure
type NocoDBResponse struct {
List []NocoDBRecord `json:"list"`
PageInfo struct {
TotalRows int `json:"totalRows"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
IsFirstPage bool `json:"isFirstPage"`
IsLastPage bool `json:"isLastPage"`
} `json:"pageInfo"`
}
type NocoDBRequest struct {
EmployeeId int `json:"Employees_id"`
ReportedBy int `json:"Employees_id1"`
InfractionId int `json:"Infractions_id"`
Date string `json:"Date"`
}
func noPointsUrl() string {
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", EmployeesTableId, "records")
return fmt.Sprintf("%s?viewId=%s", combinedUrl, NoPointsViewId)
}
func addInfractionsUrl() string {
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", InfractionsTableId, "records")
return combinedUrl
}
func firstDayOfMonth() string {
// Load America/Detroit timezone
loc, err := time.LoadLocation("America/Detroit")
if err != nil {
panic(err)
}
// Get current time in Detroit timezone
now := time.Now().In(loc)
// Create midnight of the first day of the current month
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
// Return ISO8601 formatted string
return firstDay.Format(time.RFC3339)
}
// levenshteinDistance calculates the edit distance between two strings
func levenshteinDistance(s1, s2 string) int {
s1 = strings.ToLower(s1)
s2 = strings.ToLower(s2)
len1 := len(s1)
len2 := len(s2)
matrix := make([][]int, len1+1)
for i := range matrix {
matrix[i] = make([]int, len2+1)
matrix[i][0] = i
}
for j := range matrix[0] {
matrix[0][j] = j
}
for i := 1; i <= len1; i++ {
for j := 1; j <= len2; j++ {
cost := 0
if s1[i-1] != s2[j-1] {
cost = 1
}
matrix[i][j] = min(
matrix[i-1][j]+1,
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
)
}
}
return matrix[len1][len2]
}
// normalizeName removes extra whitespace and punctuation, converts to lowercase
func normalizeName(name string) string {
// Remove extra whitespace
name = strings.TrimSpace(name)
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
// Remove common punctuation but keep hyphens
name = strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
return unicode.ToLower(r)
}
return -1
}, name)
return name
}
// convertNameFormat converts "LastName, FirstName" to "FirstName LastName"
func convertNameFormat(name string) string {
parts := strings.Split(name, ",")
if len(parts) == 2 {
lastName := strings.TrimSpace(parts[0])
firstName := strings.TrimSpace(parts[1])
return firstName + " " + lastName
}
return name
}
// toTitleCase converts a name to Title Case
func toTitleCase(name string) string {
words := strings.Fields(name)
for i, word := range words {
if len(word) > 0 {
// Handle hyphenated names
if strings.Contains(word, "-") {
parts := strings.Split(word, "-")
for j, part := range parts {
if len(part) > 0 {
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
}
}
words[i] = strings.Join(parts, "-")
} else {
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
}
}
}
return strings.Join(words, " ")
}
// parseXLSFile extracts names from the HTML-formatted XLS file
func parseXLSFile(filename string) ([]string, error) {
content, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read XLS file: %w", err)
}
// Extract names from HTML table cells with class="smallbold"
// Pattern matches: <td valign=top class="smallbold">NAME</td>
pattern := regexp.MustCompile(`<td[^>]*class="smallbold"[^>]*>([^<]+)</td>`)
matches := pattern.FindAllStringSubmatch(string(content), -1)
names := make([]string, 0)
seen := make(map[string]bool)
for _, match := range matches {
if len(match) > 1 {
name := strings.TrimSpace(match[1])
// Filter out non-name entries (like "Regular Shift", "Total:", etc.)
if name != "" && !strings.Contains(name, "Total") &&
!strings.Contains(name, "Shift") && !strings.Contains(name, ":") &&
strings.Contains(name, ",") { // Names should have comma
normalized := normalizeName(name)
if !seen[normalized] {
seen[normalized] = true
names = append(names, name)
}
}
}
}
return names, nil
}
// fetchNamesFromAPI fetches names from the NocoDB API
func fetchFromAPI(apiURL, apiToken string) ([]NocoDBRecord, error) {
records := []NocoDBRecord{}
offset := 0
limit := 25
isLastPage := false
for !isLastPage {
// Build URL with pagination
url := fmt.Sprintf("%s&offset=%d&limit=%d", apiURL, offset, limit)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
// Read and parse response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var nocoDBResp NocoDBResponse
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}
// Extract names from records
records = append(records, nocoDBResp.List...)
// Check if we've reached the last page
isLastPage = nocoDBResp.PageInfo.IsLastPage
// Update offset for next page
offset += limit
}
return records, nil
}
func hasMatch(record NocoDBRecord, candidates []string, threshold int) bool {
normalizedName := normalizeName(toTitleCase(record.Name))
bestDistance := threshold + 1
for _, candidate := range candidates {
normalizedCandidate := normalizeName(candidate)
distance := levenshteinDistance(normalizedName, normalizedCandidate)
if distance < bestDistance {
bestDistance = distance
}
}
if bestDistance <= threshold {
return true
}
return false
}
func main() {
// Define command-line flags
xlsFile := flag.String("xls", "", "Path to the XLS file (required)")
apiToken := flag.String("api-token", "", "NocoDB API token (or set NOCODB_API_TOKEN environment variable)")
threshold := flag.Int("threshold", 3, "Maximum edit distance for fuzzy matching")
flag.Parse()
// Get API token from environment if not provided via flag
token := *apiToken
if token == "" {
token = os.Getenv("NOCODB_API_TOKEN")
}
// Validate required arguments
if *xlsFile == "" {
fmt.Println("Error: -xls flag is required")
fmt.Println("\nUsage:")
flag.PrintDefaults()
os.Exit(1)
}
if token == "" {
fmt.Println("Error: API token is required")
fmt.Println("Provide it via -api-token flag or NOCODB_API_TOKEN environment variable")
fmt.Println("\nUsage:")
flag.PrintDefaults()
os.Exit(1)
}
xlsNames, err := parseXLSFile(*xlsFile)
if err != nil {
fmt.Printf("Error parsing XLS file: %v\n", err)
os.Exit(1)
}
records, err := fetchFromAPI(noPointsUrl(), token)
if err != nil {
fmt.Printf("Error fetching data from API: %v\n", err)
os.Exit(1)
}
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
xlsNamesConverted := make([]string, len(xlsNames))
for i, name := range xlsNames {
converted := convertNameFormat(name)
xlsNamesConverted[i] = toTitleCase(converted)
}
overlaps := lo.Filter(records, func(r NocoDBRecord, _ int) bool {
return hasMatch(r, xlsNamesConverted, *threshold)
})
requestObjects := lo.Map(overlaps, func(r NocoDBRecord, _ int) NocoDBRequest {
return NocoDBRequest{
EmployeeId: r.ID,
ReportedBy: EugeneId,
InfractionId: NegativeInfractionId,
Date: firstDayOfMonth(),
}
})
// Marshal request objects to JSON
jsonData, err := json.Marshal(requestObjects)
if err != nil {
fmt.Printf("Error marshaling request objects: %v\n", err)
os.Exit(1)
}
// Create POST request
req, err := http.NewRequest("POST", addInfractionsUrl(), strings.NewReader(string(jsonData)))
if err != nil {
fmt.Printf("Error creating POST request: %v\n", err)
os.Exit(1)
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Error submitting request: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
fmt.Printf("Successfully submitted %d records\n", len(requestObjects))
}

1
frontend/.env Symbolic link
View File

@@ -0,0 +1 @@
../.env

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

28
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

59
frontend/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "calculate_negative_points",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bunx --bun vite",
"build": "bunx --bun vite build --emptyOutDir",
"lint": "bunx --bun eslint .",
"typecheck": "bunx --bun tsc --noEmit",
"generate-api-types": "bunx --bun openapi-typescript ../docs/openapi/api.yaml --output ./src/api/generated/schema.ts",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-form": "^1.28.0",
"@tanstack/react-query": "^5.79.2",
"@tanstack/react-router": "^1.120.12",
"@types/lodash": "^4.17.17",
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"flatpickr": "^4.6.13",
"framer-motion": "^12.16.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"oidc-client-ts": "^3.2.1",
"pluralize": "^8.0.0",
"react": "^19.1.0",
"react-bootstrap-date-picker": "^5.1.0",
"react-dom": "^19.1.0",
"react-flatpickr": "^4.0.10",
"react-oidc-context": "^3.3.0",
"reactstrap": "^9.2.3",
"sass": "^1.89.2"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@rollup/plugin-image": "^3.0.3",
"@tanstack/react-router-devtools": "^1.120.12",
"@tanstack/router-plugin": "^1.120.12",
"@types/classnames": "^2.3.4",
"@types/flatpickr": "^3.1.4",
"@types/js-cookie": "^3.0.6",
"@types/pluralize": "^0.0.33",
"@types/react": "^19.1.2",
"@types/react-bootstrap-date-picker": "^4.0.12",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react-swc": "^3.9.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"openapi-typescript": "^7.8.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,150 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/process": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Negative Points Processor
* @description Process Negative Points
*/
post: operations["requests/negative_points_processor.NegativePointsProcessor"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/current": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Current User
* @description Retrieve the current user
*/
get: operations["requests/users.GetCurrentUser"];
put?: never;
post?: never;
/**
* Logout
* @description Logout current user
*/
delete: operations["requests/users.Logout"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
FormDataNegativePointsProcessorNegativePointsProcessorInput: {
/** @description XLS schedule file to process */
file?: components["schemas"]["MultipartFileHeader"];
};
/** Format: binary */
MultipartFileHeader: string;
NegativePointsProcessorNegativePointsProcessorOutput: {
/** @description List of employees who had negative points */
employees: string[];
};
TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser: {
item: components["schemas"]["TypesUiUser"];
};
TypesUiUser: {
first_name?: string;
groups?: string[];
id: string;
last_name?: string;
name: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
"requests/negative_points_processor.NegativePointsProcessor": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"multipart/form-data": components["schemas"]["FormDataNegativePointsProcessorNegativePointsProcessorInput"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NegativePointsProcessorNegativePointsProcessorOutput"];
};
};
};
};
"requests/users.GetCurrentUser": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser"];
};
};
};
};
"requests/users.Logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": null | {
[key: string]: unknown;
};
};
};
};
};
}

View File

@@ -0,0 +1,3 @@
export * from "./request";
export * from "./users";
export * from "./process";

View File

@@ -0,0 +1,13 @@
import * as schema from "./generated/schema";
import { request } from "@/api";
type ProcessPath = schema.paths["/api/process"];
type ProcessResponse =
ProcessPath["post"]["responses"]["200"]["content"]["application/json"];
export const processFile = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
return await request.postFormData<ProcessResponse>("/api/process", formData);
};

121
frontend/src/api/request.ts Normal file
View File

@@ -0,0 +1,121 @@
import axios, { AxiosError, AxiosPromise } from "axios";
const jsonHeaders = (additional?: { [x: string]: string }) => ({
Accept: "application/json",
"Content-Type": "application/json",
...additional,
});
const withUnauthorizedRedirect = async <T>(axiosFn: () => AxiosPromise<T>) => {
try {
const res = await axiosFn();
return res;
} catch (e: unknown) {
const status = (e as AxiosError).response?.status;
if (status && [401, 403].includes(status)) {
window.location.href = `/auth/login?returnTo=${window.location.href}`;
}
throw e;
}
};
type GetRequest = { url: string } & (
| {
pagination?: false;
}
| {
pagination: true;
page: number;
page_size: number;
}
);
export const request = {
getUnauthenticated: async <T>(url: string): Promise<T> => {
const { data } = await axios.get<T>(url, {
headers: jsonHeaders(),
withCredentials: true,
});
return data;
},
get: async <T>(request: GetRequest): Promise<T> => {
const params =
request.pagination === true
? { page: request.page, page_size: request.page_size }
: undefined;
const { data } = await withUnauthorizedRedirect(() =>
axios.get<T>(request.url, {
headers: jsonHeaders(),
params,
withCredentials: true,
}),
);
return data;
},
put: async <T>(url: string, body: T | null): Promise<T> => {
const { data } = await withUnauthorizedRedirect(() => {
return axios.put<T>(url, body, {
headers: jsonHeaders(),
withCredentials: true,
});
});
return data;
},
post: async <T>(url: string, body: T): Promise<T> => {
const { data } = await withUnauthorizedRedirect(() => {
return axios.post<T>(url, body, {
headers: jsonHeaders(),
withCredentials: true,
});
});
return data;
},
delete: async <T>(url: string): Promise<T> => {
console.log(url);
const { data } = await withUnauthorizedRedirect(() => {
return axios.delete<T>(url, {
headers: jsonHeaders(),
withCredentials: true,
});
});
return data;
},
postFormData: async <T>(url: string, body: FormData): Promise<T> => {
const { data } = await withUnauthorizedRedirect(() => {
return axios.post<T>(url, body, {
headers: { Accept: "application/json" },
withCredentials: true,
});
});
return data;
},
download: async <T>(url: string, body: T) => {
const response = await withUnauthorizedRedirect(() => {
return axios.post<Blob>(url, body, {
responseType: "blob",
withCredentials: true,
});
});
console.log({ response });
const contentDisposition = response.headers["content-disposition"];
let filename = "";
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (match) filename = match[1];
}
const blob = new Blob([response.data]);
const urlObj = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = urlObj;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(urlObj);
a.remove();
},
};

16
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as schema from "./generated/schema";
import { request } from "@/api";
type CurrentUserPath = schema.paths["/api/users/current"];
type CurrentUserResponse =
CurrentUserPath["get"]["responses"]["200"]["content"]["application/json"];
export type User = CurrentUserResponse["user"];
export const getCurrentUser = async () => {
return await request.getUnauthenticated<CurrentUserResponse>(
"/api/users/current"
);
};
export const logoutUser = async () => {
return await request.delete("/api/users/current");
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,65 @@
import {
Navbar,
Nav,
NavbarBrand,
NavItem,
NavLink,
Button,
UncontrolledDropdown,
DropdownToggle,
DropdownMenu,
DropdownItem,
} from "reactstrap";
import { logoutUser } from "@/api";
import assetUrl from "@/assets/logo.png";
import { useRouter } from "@tanstack/react-router";
import { useCallback } from "react";
import { useUser } from "@/hooks";
export const NavbarComponent: React.FC = () => {
const imgUrl = new URL(assetUrl, import.meta.url).href;
const router = useRouter();
const onLogout = useCallback(() => {
logoutUser().then(() => router.invalidate());
}, [router]);
const user = useUser();
return (
<Navbar color="light" expand="md">
<Nav navbar className="align-items-center">
<NavbarBrand href="/">
<img src={imgUrl} alt="CAASA Logo" width="48" />
</NavbarBrand>
<NavItem>
<NavLink href="/">Home</NavLink>
</NavItem>
</Nav>
<Nav>
{user ? (
<UncontrolledDropdown
inNavbar
nav
title={user.name}
id="basic-nav-dropdown"
>
<DropdownToggle nav caret>
{user.name}
</DropdownToggle>
<DropdownMenu right>
<DropdownItem onClick={onLogout}>Logout</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
) : (
<Button
color="link"
onClick={() =>
(window.location.href = `/auth/login?returnTo=${window.location.href}`)
}
>
Login
</Button>
)}
</Nav>
</Navbar>
);
};

View File

@@ -0,0 +1,20 @@
import { useRoles } from "@/hooks";
import { every } from "lodash";
type ProtectedWrapperProps = {
requiredRoles: string[];
children: React.ReactNode;
};
export const ProtectedWrapper: React.FC<ProtectedWrapperProps> = ({
requiredRoles,
children,
}) => {
const roles = useRoles();
if (!every(requiredRoles, (r) => roles.includes(r))) {
return <></>;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,54 @@
import { AnimatePresence, motion } from "framer-motion";
import { usePortal } from "@/hooks";
import { Button, Container } from "reactstrap";
export type SidePanelProps = {
children: React.ReactNode;
isVisible?: boolean;
onClose: () => void;
onOpen?: () => void;
style?: { [x: string]: string };
title: React.ReactNode;
};
export const SidePanel: React.FC<SidePanelProps> = ({
children,
isVisible,
onClose,
style = {},
title,
}) => {
const { portalRoot, createPortal } = usePortal();
return createPortal(
<AnimatePresence>
{isVisible && (
<motion.aside
className="sidepanel d-flex flex-column"
role="complementary"
initial={{ x: "100%" }}
animate={{ x: "0%" }}
exit={{ x: "100%" }}
transition={{ duration: 0.4 }}
key="side-panel"
style={style}
>
<Container>
<h4>{title}</h4>
</Container>
<hr className="w-100 flex-shrink-0" />
<Container className="d-flex flex-column flex-grow-1">
{children}
</Container>
<Button className="text-start" color="link" onClick={onClose}>
Cancel
</Button>
</motion.aside>
)}
</AnimatePresence>,
portalRoot
);
};

View File

@@ -0,0 +1 @@
export * from "./SidePanel";

View File

@@ -0,0 +1 @@
export const SIDEPANEL_ANIMATION_TIME = 400 as const;

View File

@@ -0,0 +1,3 @@
export * from "./Navbar";
export * from "./ProtectedWrapper";
export * from "./SidePanel";

View File

@@ -0,0 +1,2 @@
export * from "./useUser";
export * from "./usePortal";

View File

@@ -0,0 +1,24 @@
import { useRef, useEffect } from "react";
import { createPortal } from "react-dom";
export const usePortal = () => {
const ref = useRef<HTMLDivElement>(document.createElement("div"));
const node = ref.current;
useEffect(() => {
if (!node) return;
node.id = "portal";
document.body.appendChild(node);
return () => {
if (!node) return;
document.body.removeChild(node);
};
}, [node]);
return {
portalRoot: node,
createPortal,
};
};

View File

@@ -0,0 +1,19 @@
import { Route } from "@/routes/__root";
export const useRoles = () => {
const user = useUser();
if (!user?.groups) return [];
return user.groups;
};
export const useUser = () => {
const data = Route.useLoaderData();
if (!data) return undefined;
const { item } = data;
return item;
};

24
frontend/src/main.scss Normal file
View File

@@ -0,0 +1,24 @@
aside.sidepanel {
background-color: var(--bs-body-bg);
bottom: 0;
box-shadow: -20px 0 25px -5px rgba(0, 0, 0, 0.1), -10px 0 10px -5px rgba(0, 0, 0, 0.04);
padding: 24px 20px;
position: fixed;
overflow: auto;
right: 0;
top: 74px;
width: 100%;
z-index: 2;
// Medium screens (768px+): 50% width
@media (min-width: 768px) {
padding: 36px 30px;
width: 50%;
}
// Large screens (992px+): 33% width
@media (min-width: 992px) {
padding: 48px 40px;
width: 33%;
}
}

26
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./main.scss";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Create a new router instance
const router = createRouter({ routeTree });
export const API_URL = "/api" as const;
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);

View File

@@ -0,0 +1,59 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root"
import { Route as IndexRouteImport } from "./routes/index"
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
"/": typeof IndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/"
fileRoutesByTo: FileRoutesByTo
to: "/"
id: "__root__" | "/"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/"
path: "/"
fullPath: "/"
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -0,0 +1,24 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { NavbarComponent } from "@/components";
import { getCurrentUser } from "@/api";
export const Route = createRootRoute({
component: () => {
return (
<>
<NavbarComponent />
<Outlet />
<TanStackRouterDevtools />
</>
);
},
loader: getCurrentUser,
errorComponent: () => (
<>
<NavbarComponent />
<div>Login to continue</div>
<TanStackRouterDevtools />
</>
),
});

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { processFile } from "@/api/process";
const twoMonthsAgo = () => {
const now = new Date();
const target = new Date(now.getFullYear(), now.getMonth() - 2, 1);
const end = new Date(target.getFullYear(), target.getMonth() + 1, 0);
const month = target.toLocaleString("en-US", { month: "long" });
return `${month} 1 - ${month} ${end.getDate()}`;
};
const Index = () => {
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<
"idle" | "uploading" | "success" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
const [employees, setEmployees] = useState<string[]>([]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setStatus("uploading");
setErrorMessage("");
try {
const result = await processFile(file);
setEmployees(result.employees);
setStatus("success");
setFile(null);
} catch (err: unknown) {
setStatus("error");
setErrorMessage(
err instanceof Error ? err.message : "Failed to process file",
);
}
};
return (
<div className="p-4">
<h3>Process Negative Points</h3>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="file" className="form-label">
Upload XLS Schedule File
</label>
<input
id="file"
type="file"
className="form-control"
accept=".xls,.xlsx"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setStatus("idle");
}}
/>
<div className="form-text">
This file must come from ESO. To get it, go to{" "}
<strong>
ESO Scheduler &gt; Employees &gt; Employee Reports &gt; Employee
Hours Worked By Date Span
</strong>
.<br /> Select the following date range:{" "}
<strong>{twoMonthsAgo()}</strong>
</div>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={!file || status === "uploading"}
>
{status === "uploading" ? "Processing..." : "Upload & Process"}
</button>
</form>
{status === "success" && (
<div className="alert alert-success mt-3">
<strong>File processed successfully.</strong>
{employees.length > 0 && (
<>
<p className="mb-1 mt-2">Employees processed ({employees.length}):</p>
<ul className="mb-0">
{employees.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</>
)}
</div>
)}
{status === "error" && (
<div className="alert alert-danger mt-3">{errorMessage}</div>
)}
</div>
);
};
export const Route = createFileRoute("/")({
component: Index,
});

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"target": "ES2022",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Imports */
"paths": {
"@/*": ["./src/*"],
"@/test/*": ["./test/*"]
}
},
"include": ["./src", "./test"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

34
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import image from "@rollup/plugin-image";
import path from "node:path";
// https://vite.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite({
autoCodeSplitting: true,
quoteStyle: "double",
target: "react",
}),
image(),
react(),
],
resolve: {
alias: {
"@/test": path.resolve(__dirname, "./test"),
"@": path.resolve(__dirname, "./src"),
},
},
build: {
manifest: true,
sourcemap: true,
rollupOptions: {
input: {
reactApp: "./src/main.tsx",
},
},
outDir: "../public",
},
});

71
go.mod
View File

@@ -1,7 +1,72 @@
module clintonambulance.com/calculate_negative_points module clintonambulance.com/calculate_negative_points
go 1.25.5 go 1.24.0
require github.com/samber/lo v1.52.0 require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dsa0x/sicher v0.2.4
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/cors v1.2.2
github.com/go-playground/validator/v10 v10.30.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/goforj/godump v1.9.0
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/providers/posflag v1.0.1
github.com/knadh/koanf/providers/structs v1.0.0
github.com/knadh/koanf/v2 v2.3.2
github.com/mitchellh/mapstructure v1.5.0
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/samber/lo v1.39.0
github.com/spf13/pflag v1.0.6
github.com/swaggest/openapi-go v0.2.59
github.com/swaggest/rest v0.2.75
github.com/swaggest/usecase v1.3.1
go.uber.org/zap v1.27.1
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.33.0
resty.dev/v3 v3.0.0-beta.6
)
require golang.org/x/text v0.22.0 // indirect require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect
github.com/swaggest/form/v5 v5.1.1 // indirect
github.com/swaggest/jsonschema-go v0.3.78 // indirect
github.com/swaggest/refl v1.4.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

192
go.sum
View File

@@ -1,4 +1,188 @@
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU=
github.com/bool64/dev v0.2.40/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dsa0x/sicher v0.2.4 h1:XIOsyylc3lQ5Wf7PPIq5R80RggX7b7d0KNqHXvHqOhc=
github.com/dsa0x/sicher v0.2.4/go.mod h1:3+m3tC4maosJU24v/yJhXYqQxMbScCYIccryM9P9dGA=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goforj/godump v1.9.0 h1:Y/APfWKQKnJetXgVJxDqD7vEpTGSgAwbKJGmj0UAteI=
github.com/goforj/godump v1.9.0/go.mod h1:/Vy+p50JtOkwsFN5dA1HQ7LS5gtPk3f61DaP4UR2o4s=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8=
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4=
github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w=
github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=
github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw=
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY=
github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg=
github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw=
github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g=
github.com/swaggest/openapi-go v0.2.59 h1:9cUlCrSxbWn/Qn78IxitrhB5kaev0hOROfTxwywYLC0=
github.com/swaggest/openapi-go v0.2.59/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk=
github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k=
github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
github.com/swaggest/rest v0.2.75 h1:MW9zZ3d0kduJ2KdWnSYZIIrZJ1v3Kg+S7QZrDCZcXws=
github.com/swaggest/rest v0.2.75/go.mod h1:yw+PNgpNSdD6W46r60keVXdsBB+7SKt64i2qpeuBsq4=
github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w=
github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=

View File

@@ -0,0 +1,15 @@
package middleware
import (
"net/http"
"github.com/go-chi/chi/v5/middleware"
)
func AddRequestIDHeaderMiddleware(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(middleware.RequestIDHeader, middleware.GetReqID(r.Context()))
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

View File

@@ -0,0 +1,118 @@
package middleware
import (
"context"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type Claims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
func verifyTokenAndGetClaims(config *config.ApplicationConfig, ctx context.Context, token string) (*oidc.IDToken, Claims, error) {
idToken, err := config.OidcConfig.Verifier.Verify(ctx, token)
claims := Claims{Groups: []string{}}
if err != nil {
return idToken, claims, err
}
err = idToken.Claims(&claims)
if err != nil {
return idToken, claims, err
}
return idToken, claims, nil
}
func serveWithTokenAndClaims(next http.Handler, r *http.Request, w http.ResponseWriter, claims Claims, token *oidc.IDToken) {
ctx := context.WithValue(r.Context(), "user", token)
ctx = context.WithValue(ctx, "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
}
func CurrentUserMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
rawIDToken, ok := session.Values["id_token"].(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// In test environment, bypass OIDC verification and use mock claims
if config.Environment == "test" || config.Environment == "testEnvironment" {
mockClaims := Claims{
Sub: "test-user-id",
Name: "Test User",
Email: "test@example.com",
Groups: []string{"calculate-negative-points-users"},
}
serveWithTokenAndClaims(next, r, w, mockClaims, nil)
return
}
idToken, claims, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
if err == nil {
serveWithTokenAndClaims(next, r, w, claims, idToken)
return
}
refreshToken, ok := session.Values["refresh_token"].(string)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Attempt refresh
tokenSrc := config.OidcConfig.OAuth2.TokenSource(r.Context(), &oauth2.Token{
RefreshToken: refreshToken,
})
newToken, err := tokenSrc.Token()
if err != nil {
http.Error(w, "failed to refresh token", http.StatusUnauthorized)
return
}
// Save new tokens
newRawIDToken, ok := newToken.Extra("id_token").(string)
if !ok {
http.Error(w, "missing id_token", http.StatusUnauthorized)
return
}
idToken, claims, err = verifyTokenAndGetClaims(config, r.Context(), newRawIDToken)
if err != nil {
http.Error(w, "invalid refreshed token", http.StatusUnauthorized)
return
}
session.Values["id_token"] = newRawIDToken
if newToken.RefreshToken != "" {
session.Values["refresh_token"] = newToken.RefreshToken
}
if scope, ok := newToken.Extra("scope").(string); ok {
session.Values["scope"] = scope
}
session.Save(r, w)
serveWithTokenAndClaims(next, r, w, claims, idToken)
return
})
}
return middleware, nil
}

View File

@@ -0,0 +1,64 @@
package middleware
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
// "github.com/getsentry/sentry-go"
"clintonambulance.com/calculate_negative_points/internal/utils"
"github.com/swaggest/rest"
"github.com/swaggest/rest/nethttp"
)
func ErrorResponder(w http.ResponseWriter, error string, code int) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(utils.NewApplicationError(error, code))
}
type errorWithFields interface {
error
Fields() map[string]interface{}
}
// ErrorHandler middleware centralizes error handling and translation of errors between different error types
func ErrorHandler(next http.Handler) http.Handler {
var h *nethttp.Handler
if nethttp.HandlerAs(next, &h) {
resp := func(ctx context.Context, err error) (int, interface{}) {
// Handle AWS errors
// Application-specific error handling: if this error has a marker interface, serialize it as JSON
var customErr utils.CustomApplicationError
if errors.As(err, &customErr) {
return customErr.HTTPStatus(), customErr
}
// More detailed validation error messages
var validationErr errorWithFields
if errors.As(err, &validationErr) {
detailedErrorMessages := make([]string, len(validationErr.Fields()))
i := 0
for k, v := range validationErr.Fields() {
detailedErrorMessages[i] = fmt.Sprintf("%s: %v", k, v)
i++
}
return http.StatusUnprocessableEntity, utils.ApplicationError{
Message: fmt.Sprintf("Validation errors: %s", strings.Join(detailedErrorMessages, ", ")),
}
}
code, er := rest.Err(err)
return code, utils.ApplicationError{
Message: er.ErrorText,
}
}
h.MakeErrResp = resp
}
return next
}

View File

@@ -0,0 +1,77 @@
package middleware
import (
"net/http"
"strings"
"clintonambulance.com/calculate_negative_points/internal/config"
)
type contextKey string
const userContextKey = contextKey("user")
func audMatch(aud interface{}, expected string) bool {
switch v := aud.(type) {
case string:
return v == expected
case []interface{}:
for _, val := range v {
if s, ok := val.(string); ok && s == expected {
return true
}
}
}
return false
}
func JWTMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodDelete {
// Skip auth for safe methods like GET/HEAD
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
// Check if the header exists and starts with "Bearer "
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized: Bearer token missing or invalid", http.StatusUnauthorized)
return
}
// Extract the token by removing the "Bearer " prefix
//tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
//token, err := jwt.Parse(tokenStr, config.Jwt.KeySet.Keyfunc)
//if err != nil || !token.Valid {
// http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
// return
//}
//claims, ok := token.Claims.(jwt.MapClaims)
//if !ok {
// http.Error(w, "Invalid token claims", http.StatusUnauthorized)
// return
//}
// Check `iss` and `aud`
// if claims["iss"] != config.Jwt.Issuer {
// http.Error(w, "Invalid issuer", http.StatusUnauthorized)
// return
// }
// audClaim := claims["aud"]
// if !audMatch(audClaim, config.Jwt.Audience) {
// http.Error(w, "Invalid audience", http.StatusUnauthorized)
// return
// }
//ctx := context.WithValue(r.Context(), userContextKey, claims)
next.ServeHTTP(w, r.WithContext(r.Context()))
})
}
return middleware, nil
}

View File

@@ -0,0 +1,50 @@
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5/middleware"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type ContextKey string
const (
LoggerContext = ContextKey("logger")
)
func LoggingMiddleware(logger *zap.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
defer func() {
if ww.Status() < 300 && strings.HasPrefix(r.URL.Path, "/health") {
// Don't log health checks unless they fail (ping endpoint returns empty response, HTTP status 204).
return
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
fields := []zapcore.Field{
zap.Any("body", r.Body),
zap.String("remote_ip", r.RemoteAddr),
zap.String("method", r.Method),
zap.String("uri", r.URL.Path),
zap.String("request_id", middleware.GetReqID(r.Context())),
zap.Int("status", ww.Status()),
zap.Float64("latency_ms", float64(time.Since(start))/float64(time.Millisecond)),
zap.Int("size", ww.BytesWritten()),
}
logger.Info("HTTP request processed", fields...)
}()
r = r.WithContext(context.WithValue(r.Context(), LoggerContext, logger))
next.ServeHTTP(ww, r)
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
)
func Logout(config *config.ApplicationConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
http.Error(w, "Failed to delete session", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
return
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,36 @@
package middleware
import (
"context"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/config"
)
func OidcMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
middleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, _ := config.CookieStore.Get(r, config.SessionName)
rawIDToken, ok := session.Values["id_token"].(string)
if !ok {
// Not authenticated; redirect to login
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
idToken, _, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
if err != nil {
session.Options.MaxAge = -1
session.Save(r, w)
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
// Add token to context
ctx := context.WithValue(r.Context(), "id_token", idToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
return middleware, nil
}

View File

@@ -0,0 +1,48 @@
package middleware
import (
"context"
"net/http"
"strconv"
)
type Pagination struct {
Page int
PageSize int
}
const PaginationContextKey = "pagination"
func PaginationMiddleware() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
page := 1
pageSize := 20
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed >= 1 {
page = parsed
}
}
if ps := r.URL.Query().Get("page_size"); ps != "" {
if parsed, err := strconv.Atoi(ps); err == nil && parsed >= 1 {
pageSize = parsed
}
}
if pageSize > 100 {
pageSize = 100
}
ctx := context.WithValue(r.Context(), PaginationContextKey, Pagination{
Page: page,
PageSize: pageSize,
})
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
}

View File

@@ -0,0 +1,36 @@
package views_api
import (
"log"
"net/http"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
"clintonambulance.com/calculate_negative_points/internal/api/requests/negative_points_processor"
"clintonambulance.com/calculate_negative_points/internal/api/requests/users"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/swaggest/rest/nethttp"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
func MountInternalApiEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
e.Route("/api", func(r chi.Router) {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"*"},
AllowCredentials: true,
}))
currentUserMiddlware, err := middleware.CurrentUserMiddleware(config)
if err != nil {
log.Fatal(err)
}
r.Use(currentUserMiddlware)
r.Method(http.MethodGet, "/users/current", nethttp.NewHandler(users.GetCurrentUser()))
r.Method(http.MethodPost, "/process", nethttp.NewHandler(negative_points_processor.NegativePointsProcessor(config, logger)))
r.With(middleware.Logout(config)).Method(http.MethodDelete, "/users/current", nethttp.NewHandler(users.Logout()))
})
}

View File

@@ -0,0 +1,139 @@
package negative_points_processor
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/nocodb"
"clintonambulance.com/calculate_negative_points/internal/utils"
"github.com/samber/lo"
"github.com/swaggest/usecase"
"go.uber.org/zap"
)
type negativePointsProcessorInput struct {
File *multipart.FileHeader `formData:"file" description:"XLS schedule file to process"`
}
type negativePointsProcessorOutput struct {
Employees []string `json:"employees" description:"List of employees who had negative points" nullable:"false" required:"true"`
}
func hasMatch(record nocodb.NocoDBRecord, candidates []string, threshold int) bool {
normalizedName := utils.NormalizeName(utils.ToTitleCase(record.Name))
bestDistance := threshold + 1
for _, candidate := range candidates {
normalizedCandidate := utils.NormalizeName(candidate)
distance := utils.LevenshteinDistance(normalizedName, normalizedCandidate)
if distance < bestDistance {
bestDistance = distance
}
}
if bestDistance <= threshold {
return true
}
return false
}
func NegativePointsProcessor(config *config.ApplicationConfig, logger *zap.Logger) usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input negativePointsProcessorInput, output *negativePointsProcessorOutput) error {
ctxUser := ctx.Value("claims").(middleware.Claims)
file, err := input.File.Open()
if err != nil {
return err
}
defer file.Close()
employees, err := utils.ParseUploadedXLSFile(input.File)
if err != nil {
return err
}
records, err := nocodb.Fetch(config)
if err != nil {
return err
}
employees = lo.Filter(employees, func(e utils.Employee, _ int) bool { return e.Worked() })
names := lo.Map(employees, func(e utils.Employee, _ int) string { return e.Name })
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
xlsNamesConverted := lo.Map(names, func(name string, _ int) string {
return utils.ToTitleCase(utils.ConvertNameFormat(name))
})
overlaps := lo.Filter(records, func(r nocodb.NocoDBRecord, _ int) bool {
return hasMatch(r, xlsNamesConverted, config.MatchThreshold)
})
currentNocodbUser, found := lo.Find(records, func(r nocodb.NocoDBRecord) bool { return hasMatch(r, []string{ctxUser.Name}, config.MatchThreshold) })
if !found {
return errors.New("Unable to match API user to NocoDB user")
}
requestObjects := lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) nocodb.NocoDBRequest {
return nocodb.NocoDBRequest{
EmployeeId: r.ID,
ReportedBy: currentNocodbUser.ID,
InfractionId: config.NocoDBConfig.NegativeInfractionId,
Date: utils.FirstDayOfMonth(),
}
})
// Marshal request objects to JSON
jsonData, err := json.Marshal(requestObjects)
if err != nil {
return err
}
// Create POST request
req, err := http.NewRequest("POST", nocodb.AddInfractionsUrl(config), strings.NewReader(string(jsonData)))
if err != nil {
return err
}
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
os.Exit(1)
}
output.Employees = lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) string { return r.Name })
return nil
})
u.SetDescription("Process Negative Points")
u.SetTags("Negative Points Processor")
return u
}

View File

@@ -0,0 +1,31 @@
package users
import (
"context"
"go/types"
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
internal_types "clintonambulance.com/calculate_negative_points/internal/types"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/samber/lo"
"github.com/swaggest/usecase"
)
func GetCurrentUser() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *internal_types.UiUserResponse) error {
ctxId := ctx.Value("user").(*oidc.IDToken)
ctxUser := ctx.Value("claims").(middleware.Claims)
if lo.IsNotEmpty(ctxId.Issuer) {
output.Item = internal_types.UiUser{
Name: ctxUser.Name,
}
}
return nil
})
u.SetDescription("Retrieve the current user")
u.SetTags("Users")
return u
}

View File

@@ -0,0 +1,17 @@
package users
import (
"context"
"go/types"
"github.com/swaggest/usecase"
)
func Logout() usecase.Interactor {
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *map[string]interface{}) error {
return nil
})
u.SetDescription("Logout current user")
u.SetTags("Users")
return u
}

335
internal/config/config.go Normal file
View File

@@ -0,0 +1,335 @@
package config
import (
"context"
"fmt"
"log"
"net/http"
"os"
"reflect"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/dsa0x/sicher"
"github.com/go-playground/validator/v10"
"github.com/go-viper/mapstructure/v2"
"github.com/gorilla/sessions"
"github.com/joho/godotenv"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/providers/structs"
"github.com/knadh/koanf/v2"
"github.com/samber/lo"
"github.com/spf13/pflag"
"go.uber.org/zap"
"golang.org/x/oauth2"
)
const defaultEnvironment = "development"
type SicherObject interface {
SetEnvStyle(string)
LoadEnv(string, interface{}) error
}
type SicherConfig struct {
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
}
var NewSicherObject = func(environment string, path string) SicherObject {
return sicher.New(environment, path)
}
type OidcConfig struct {
ClientId string `koanf:"oidc.client_id" validate:"required"`
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
Issuer string `koanf:"oidc.issuer" validate:"required"`
Scopes []string `koanf:"oidc.scopes" validate:"required"`
Provider *oidc.Provider
Verifier *oidc.IDTokenVerifier
OAuth2 *oauth2.Config
}
type IdmsConfig struct {
BaseUrl string `koanf:"idms.base_url" validate:"required"`
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
}
type NocoDBConfig struct {
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
}
type ApplicationConfig struct {
AppSecret Secret[string] `koanf:"app_secret"`
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
CookieStore *sessions.CookieStore
Environment string `koanf:"environment" validate:"required"`
Listen string `koanf:"listen" validate:"required,hostname_port"`
MatchThreshold int `koanf:"match_threshold" validate:"required"`
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
PublicPath string `koanf:"paths.public"`
Idms IdmsConfig `koanf:"idms" validate:"required"`
SessionName string
ViewPath string `koanf:"paths.views"`
}
type ParsedCliArgs struct {
ConfigFiles *[]string
DatabasePasswordPath *string
Environment *string
Listen *string
Version *bool
}
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
f.Usage = func() {
fmt.Println(version.AppNameAndVersion(false))
fmt.Println(f.FlagUsages())
exit(0)
}
parsedCliArgs := &ParsedCliArgs{
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
Version: f.Bool("version", false, "Show version"),
}
err := f.Parse(args[1:])
if err != nil {
_, _ = fmt.Fprintf(
os.Stderr,
"%s\n\nERROR: %s\n\n%s",
version.AppNameAndVersion(false), err, f.FlagUsages(),
)
exit(1)
}
return parsedCliArgs, f
}
// Load application configuration from config files, command line flags, and environment variables.
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
k := koanf.New(".")
config := defaultConfig()
err := k.Load(confmap.Provider(config, "."), nil)
if err != nil {
log.Fatal("error initializing config", zap.Error(err))
}
parsedCliArgs, f := ParseArgs(version, exit, args)
// Load the config files provided in the command line.
for _, c := range *parsedCliArgs.ConfigFiles {
err := k.Load(file.Provider(c), yaml.Parser())
if err != nil {
return nil, fmt.Errorf("error loading configuration file: %v", err)
}
}
err = godotenv.Load()
if err != nil {
logger.Info("No .env file found")
}
configPath := func(p string) string {
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
return combinedPath
}
// Override with environment variables
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
logger.Fatal("error parsing environment variables", zap.Error(err))
}
})
// Override with command-line values
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
}
if err := k.Unmarshal("", &config); err != nil {
return nil, err
}
if *parsedCliArgs.Version {
fmt.Println(version.AppNameAndVersion(true))
exit(0)
}
var sicherConfig SicherConfig
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
s.SetEnvStyle("yaml") // default is dotenv
err = s.LoadEnv("", &sicherConfig)
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
cookieStore.Options.SameSite = http.SameSiteLaxMode
if err != nil {
log.Println(err)
}
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
}
var appConfig ApplicationConfig
unmarshalConf := koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
DecodeHook: mapstructure.ComposeDecodeHookFunc(
SecretFilePathUnmarshalHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
),
Metadata: nil,
Result: &appConfig,
WeaklyTypedInput: true,
},
}
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
return nil, err
}
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
nocodbConfig := NocoDBConfig{
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
BaseUrl: k.String("nocodb.base_url"),
EmployeesTableId: k.String("nocodb.employees_table_id"),
InfractionsTableId: k.String("nocodb.infractions_table_id"),
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
NoPointsViewId: k.String("nocodb.no_points_view_id"),
}
oidcConfig := OidcConfig{
ClientId: k.String("oidc.client_id"),
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
Issuer: k.String("oidc.issuer"),
Scopes: k.Strings("oidc.scopes"),
}
if *parsedCliArgs.Environment != "testEnvironment" {
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
if err != nil {
return nil, err
}
oidcConfig.Provider = authProvider
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
oidcConfig.OAuth2 = &oauth2.Config{
ClientID: k.String("oidc.client_id"),
ClientSecret: k.String("oidc.client_secret"),
Endpoint: authProvider.Endpoint(),
RedirectURL: redirectUrl,
Scopes: k.Strings("oidc.scopes"),
}
}
appConfig.CookieStore = cookieStore
appConfig.Environment = *parsedCliArgs.Environment
appConfig.Idms = IdmsConfig{
BaseUrl: k.String("idms.base_url"),
CarsGroupId: k.String("idms.cars_group_id"),
Id: SecretFromValue(k.String("idms.service_account_id")),
Password: SecretFromValue(k.String("idms.service_account_password")),
}
appConfig.Listen = *parsedCliArgs.Listen
appConfig.MatchThreshold = k.Int("match_threshold")
appConfig.NocoDBConfig = nocodbConfig
appConfig.OidcConfig = oidcConfig
appConfig.PublicPath = configPath(k.String("paths.public"))
appConfig.SessionName = "calculate-negative-points"
appConfig.ViewPath = configPath(k.String("paths.views"))
validate := validator.New()
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
err = validate.Struct(appConfig)
if err != nil {
log.Println(err)
fmt.Print("Could not create config")
exit(1)
return nil, err
}
return &appConfig, nil
}
const envPrefix = "APP_"
const pgPrefix = "PG_"
const databasePrefix = "DATABASE_"
const idmsPrefix = "IDMS_"
const oidcPrefix = "OIDC_"
/*
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
*/
func mapEnvVarNames(prefix string) func(s string) string {
return func(s string) string {
if prefix == databasePrefix {
return strings.ToLower(s)
}
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
return lower
}
}
func defaultConfig() map[string]interface{} {
dbConfig := map[string]interface{}{
"host": "localhost",
"port": 5432,
"user": "postgres",
"name": "calculate-negative-points",
}
return map[string]interface{}{
"database": dbConfig,
"environment": defaultEnvironment,
"listen": "127.0.0.1:3000",
}
}
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
if secret, ok := field.Interface().(Secret[T]); ok {
return secret.Value()
}
return nil
}

View File

@@ -0,0 +1,13 @@
package config_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestConfig(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Config Suite")
}

View File

@@ -0,0 +1,41 @@
package config_test
import (
"log"
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type FakeSicherObject struct{}
func (_ FakeSicherObject) SetEnvStyle(_ string) {}
func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error {
var err error
return err
}
var _ = Describe("ConfigTest", func() {
var version config.Version
var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
shouldNotExit := func(code int) {
// "Fatal" to mimic os.Exit
log.Default().Print("exit called with code", code)
}
BeforeEach(func() {
config.NewSicherObject = func(_ string, _ string) config.SicherObject {
return FakeSicherObject{}
}
version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"}
})
It("Errors if the config file is not found", func() {
argv := []string{"cmd", "-c", "config/no-such-file.yml"}
_, err := config.Load(version, shouldNotExit, argv, logger)
Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory"))
})
})

View File

@@ -0,0 +1,34 @@
package config
import (
"io"
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) {
parsedCliArgs, _ := ParseArgs(version, os.Exit, args)
syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)}
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
core := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zap.CombineWriteSyncers(syncs...),
zap.DebugLevel,
)
logger := zap.New(core).WithOptions(
zap.AddStacktrace(zap.ErrorLevel),
zap.WithCaller(false),
).With(
zap.String("version", version.Release),
zap.String("environment", *parsedCliArgs.Environment),
)
finalizer := func() {
// This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880)
_ = logger.Sync()
}
return logger, finalizer
}

85
internal/config/secret.go Normal file
View File

@@ -0,0 +1,85 @@
package config
import (
"bytes"
"encoding/json"
"os"
"reflect"
"github.com/mitchellh/mapstructure"
)
// SecretValue defines what types of secrets this secret container supports
type SecretValue interface {
~string | ~[]string | ~map[string]string
}
// SecretFilePath is the source to the file containing the secret.
type SecretFilePath string
const SecretPlaceholder = "[SECRET]"
// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable
type Secret[T SecretValue] struct {
value T
source SecretFilePath
}
// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc.
func (s Secret[T]) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)})
}
// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc.
func (s *Secret[T]) MarshalText() ([]byte, error) {
return []byte(SecretPlaceholder), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values
func (s *Secret[T]) UnmarshalText(text []byte) error {
var val T
switch any(val).(type) {
case string:
s.value = any(string(text)).(T)
return nil
default:
return json.Unmarshal(text, &s.value)
}
}
func (s Secret[T]) Value() T {
return s.value
}
func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) {
contentBytes, err := os.ReadFile(string(path))
if err != nil {
return Secret[T]{}, err
}
secret := Secret[T]{source: path}
err = secret.UnmarshalText(bytes.TrimSpace(contentBytes))
return secret, err
}
func SecretFromValue[T SecretValue](value T) Secret[T] {
return Secret[T]{value: value}
}
// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret
func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType {
return func(from, to reflect.Type, data interface{}) (interface{}, error) {
if from != reflect.TypeOf(SecretFilePath("")) {
return data, nil
}
// Reflection does not work with generics as of 1.20, so we have to do this manually
if to == reflect.TypeOf(Secret[map[string]string]{}) {
return SecretFromSecretPath[map[string]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[[]string]{}) {
return SecretFromSecretPath[[]string](data.(SecretFilePath))
} else if to == reflect.TypeOf(Secret[string]{}) {
return SecretFromSecretPath[string](data.(SecretFilePath))
} else {
return data, nil
}
}
}

View File

@@ -0,0 +1 @@
fakesecret

View File

@@ -0,0 +1,22 @@
app_secret: "app secret"
app_secret_block: "app secret block hash"
path_prefix: "."
paths:
migrations: "db/migrations"
credentials: "config/credentials"
match_threshold: 3
nocodb:
api_token: test
base_url: https://example.com
employees_table_id: "1234567890"
infractions_table_id: "2468013579"
negative_infraction_id: 1
no_points_view_id: "1357924680"
oidc:
issuer: http://example.com
redirect_url: http://example.com
idms:
base_url: https://example.com
cars_group_id: "1234"
id: "1234"
password: "5678"

View File

@@ -0,0 +1,34 @@
package config
import "fmt"
type Version struct {
Release string
Commit string
Date string
}
var (
release = "dev"
commit = ""
date = ""
)
func (v *Version) AppNameAndVersion(details bool) string {
result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release)
if details && v.Commit != "" {
result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit)
}
if details && v.Date != "" {
result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date)
}
return result
}
func NewVersion() Version {
return Version{
Release: release,
Commit: commit,
Date: date,
}
}

103
internal/idms/request.go Normal file
View File

@@ -0,0 +1,103 @@
package idms
import (
"fmt"
"clintonambulance.com/calculate_negative_points/internal/config"
"resty.dev/v3"
)
type Method string
const Get Method = "GET"
const Post Method = "POST"
const Put Method = "PUT"
const Delete Method = "Delete"
type ResponsePagination struct {
Count int `json:"count"`
Current int `json:"current"`
EndIndex int `json:"end_index"`
Next int `json:"next"`
Previous int `json:"previous"`
StartIndex int `json:"start_index"`
TotalPages int `json:"total_pages"`
}
type IdmsResponse[T any] struct {
Pagination ResponsePagination `json:"pagination"`
Results []T `json:"results"`
}
type TokenResponse struct {
AccessToken string `json:"access_token"`
}
type IdmsRequest[T any] struct {
Method Method
Path string
ResponseDecoder *IdmsResponse[T]
}
func newRestyRequest(url string, path string) *resty.Request {
client := resty.New()
formattedUrl := fmt.Sprintf("%s%s", url, path)
req := client.R()
req.SetURL(formattedUrl)
defer client.Close()
return req
}
func GetToken(config *config.ApplicationConfig) (string, error) {
req := newRestyRequest(config.Idms.BaseUrl, "/application/o/token/")
req.SetResult(&TokenResponse{})
req.SetMethod(string(Post))
req.SetFormData(map[string]string{
"client_id": config.Idms.Id.Value(),
"grant_type": "client_credentials",
"password": config.Idms.Password.Value(),
"scope": "goauthentik.io/api",
"username": "service",
})
res, err := req.Send()
if err != nil {
return "", err
}
data := res.Result().(*TokenResponse).AccessToken
return data, nil
}
func NewRequest[T any](path string, method Method) IdmsRequest[T] {
return IdmsRequest[T]{
Method: method,
Path: path,
ResponseDecoder: &IdmsResponse[T]{},
}
}
func (r IdmsRequest[T]) Perform(config *config.ApplicationConfig, bodyData interface{}, headers map[string]string) ([]T, error) {
req := newRestyRequest(config.Idms.BaseUrl, r.Path)
token, err := GetToken(config)
if err != nil {
return []T{}, err
}
req.SetResult(r.ResponseDecoder).SetAuthToken(token).SetContentType("application/json")
req.SetMethod(string(r.Method))
res, err := req.Send()
if err != nil {
return []T{}, err
}
data := res.Result().(*IdmsResponse[T]).Results
return data, nil
}

74
internal/idms/users.go Normal file
View File

@@ -0,0 +1,74 @@
package idms
import (
"encoding/json"
"fmt"
"sort"
"strings"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/samber/lo"
)
// FlexibleString handles JSON values that can be either strings or numbers
type FlexibleString string
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
*f = FlexibleString(s)
return nil
}
var n json.Number
if err := json.Unmarshal(data, &n); err == nil {
*f = FlexibleString(n.String())
return nil
}
return fmt.Errorf("cannot unmarshal %s into FlexibleString", data)
}
type IdmsUserAttributes struct {
Cars bool `json:"cars"`
EmployeeId FlexibleString `json:"employee_id"`
}
type IdmsUser struct {
Attributes IdmsUserAttributes `json:"attributes"`
Groups []string `json:"groups"`
Id string `json:"uid"`
Name string `json:"name"`
Username string `json:"username"`
}
func (u IdmsUser) FirstName() string {
parts := strings.Fields(u.Name)
if len(parts) == 0 {
return ""
}
return parts[0]
}
func (u IdmsUser) LastName() string {
parts := strings.Fields(u.Name)
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func GetCarsMembers(config *config.ApplicationConfig) []IdmsUser {
req := NewRequest[IdmsUser]("/api/v3/core/users/?is_active=true", Get)
results, _ := req.Perform(config, map[string]string{}, map[string]string{})
filtered := lo.Filter(results, func(u IdmsUser, _ int) bool {
return lo.Contains(u.Groups, config.Idms.CarsGroupId)
})
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].LastName() < filtered[j].LastName()
})
return filtered
}

View File

@@ -0,0 +1,13 @@
package models_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestModels(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Models Suite")
}

108
internal/nocodb/request.go Normal file
View File

@@ -0,0 +1,108 @@
package nocodb
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"clintonambulance.com/calculate_negative_points/internal/config"
)
type NocoDBRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
}
// NocoDBResponse represents the API response structure
type NocoDBResponse struct {
List []NocoDBRecord `json:"list"`
PageInfo struct {
TotalRows int `json:"totalRows"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
IsFirstPage bool `json:"isFirstPage"`
IsLastPage bool `json:"isLastPage"`
} `json:"pageInfo"`
}
type NocoDBRequest struct {
EmployeeId int `json:"Employees_id"`
ReportedBy int `json:"Employees_id1"`
InfractionId int `json:"Infractions_id"`
Date string `json:"Date"`
}
func NoPointsUrl(config *config.ApplicationConfig) string {
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.EmployeesTableId, "records")
return fmt.Sprintf("%s?viewId=%s", combinedUrl, config.NocoDBConfig.NoPointsViewId)
}
func AddInfractionsUrl(config *config.ApplicationConfig) string {
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.InfractionsTableId, "records")
return combinedUrl
}
func Fetch(config *config.ApplicationConfig) ([]NocoDBRecord, error) {
records := []NocoDBRecord{}
offset := 0
limit := 25
isLastPage := false
baseURL := NoPointsUrl(config)
for !isLastPage {
// Create HTTP request with base URL
req, err := http.NewRequest("GET", baseURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set query params via the request's URL
q := req.URL.Query()
q.Set("offset", fmt.Sprintf("%d", offset))
q.Set("limit", fmt.Sprintf("%d", limit))
req.URL.RawQuery = q.Encode()
// Add authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
req.Header.Set("Content-Type", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
// Read and parse response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var nocoDBResp NocoDBResponse
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
}
// Extract names from records
records = append(records, nocoDBResp.List...)
// Check if we've reached the last page
isLastPage = nocoDBResp.PageInfo.IsLastPage
// Update offset for next page
offset += limit
}
return records, nil
}

64
internal/server/http.go Normal file
View File

@@ -0,0 +1,64 @@
package server
import (
"encoding/json"
"net/http"
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
views_api "clintonambulance.com/calculate_negative_points/internal/api/requests"
"clintonambulance.com/calculate_negative_points/internal/config"
internal_web "clintonambulance.com/calculate_negative_points/internal/web"
"github.com/go-chi/chi/v5/middleware"
"github.com/swaggest/openapi-go/openapi31"
"github.com/swaggest/rest/response"
"github.com/swaggest/rest/web"
"go.uber.org/zap"
)
type ErrorResponse struct {
Message string `json:"message"`
}
func errorResponder(w http.ResponseWriter, error string, code int) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(ErrorResponse{Message: error})
}
func notFoundResponder(w http.ResponseWriter, _r *http.Request) {
errorResponder(w, "Not Found", http.StatusNotFound)
}
func NewHttpServer(logger *zap.Logger, version config.Version) (*web.Service, *openapi31.Reflector) {
reflector := openapi31.NewReflector()
service := web.NewService(reflector)
service.OpenAPISchema().SetTitle("Calculate Negative Points")
service.OpenAPISchema().SetDescription("Easily maintain Calculate Negative Points")
service.OpenAPISchema().SetVersion("v1.0.0")
service.Use(
middleware.RealIP,
middleware.RequestID,
apimiddleware.AddRequestIDHeaderMiddleware,
apimiddleware.LoggingMiddleware(logger),
apimiddleware.ErrorHandler,
)
service.NotFound(notFoundResponder)
return service, reflector
}
func MountAllEndpoints(srv *web.Service, version config.Version, config *config.ApplicationConfig, logger *zap.Logger) {
views_api.MountInternalApiEndpoints(srv, config, logger)
// This should be last because it does wildcard matching
internal_web.MountWebEndpoints(srv, config, logger)
}
func init() {
// Override some global defaults:
// This is just "application/json" by default, let's include the charset as well.
response.DefaultSuccessResponseContentType = "application/json; charset=UTF-8"
}

View File

@@ -0,0 +1,51 @@
package server_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestServer(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Http Suite")
}
var _ = Describe("NewHttpServer", func() {
It("Creates a new HttpServer", func() {
var logOutput bytes.Buffer
version := config.Version{Release: "test-version"}
logger, _ := config.NewLogger(version, &logOutput, []string{"cmd", "-e", "testEnvironment"})
srv, _ := server.NewHttpServer(logger, version)
srv.Method(http.MethodHead, "/dummy-route", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
rec := httptest.NewRecorder()
// Validate that the server responds with the web frontend for unknown routes
srv.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusNotFound))
Expect(rec.Header().Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(rec.Body.String()).To(ContainSubstring(`{"message":"Not Found"}`))
// Validate that it writes to the log for all requests
var logMessage map[string]interface{}
err := json.Unmarshal(logOutput.Bytes(), &logMessage)
Expect(err).ToNot(HaveOccurred())
Expect(logMessage["msg"]).To(Equal("HTTP request processed"))
Expect(logMessage["method"]).To(Equal("GET"))
Expect(logMessage["uri"]).To(Equal("/foobar"))
Expect(logMessage["status"]).To(Equal(404.0))
})
})

View File

@@ -0,0 +1,130 @@
package test
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"clintonambulance.com/calculate_negative_points/internal/config"
"clintonambulance.com/calculate_negative_points/internal/server"
"github.com/onsi/gomega/ghttp"
"github.com/swaggest/rest/web"
)
var version = config.Version{Release: "test-version", Commit: "test-commit"}
func CreateRequestBodyFromStruct(body interface{}) *bytes.Buffer {
return bytes.NewBuffer(CreateResponseBodyFromStruct(body))
}
func CreateResponseBodyFromStruct(body interface{}) []byte {
jsonValue, err := json.Marshal(body)
if err != nil {
log.Fatal("Error marshaling struct")
}
return jsonValue
}
func URLFromServerAndPath(server *ghttp.Server, path string) string {
return fmt.Sprintf("%s%s", server.URL(), path)
}
func CreateHttpServer(appConfig *config.ApplicationConfig) *web.Service {
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
defer flushLogs()
service, _ := server.NewHttpServer(logger, version)
server.MountAllEndpoints(service, version, appConfig, logger)
return service
}
// TestClaims represents the mock user claims for testing
type TestClaims struct {
Sub string
Name string
Email string
Groups []string
}
// DefaultTestClaims returns default test user claims
func DefaultTestClaims() TestClaims {
return TestClaims{
Sub: "test-user-id",
Name: "Test User",
Email: "test@example.com",
Groups: []string{"calculate-negative-points-users"},
}
}
// addMockSessionCookie adds a mock session cookie to the request with the given claims
func addMockSessionCookie(req *http.Request, appConfig *config.ApplicationConfig) {
// Create a mock session with a fake id_token
session, _ := appConfig.CookieStore.New(req, appConfig.SessionName)
session.Values["id_token"] = "mock-test-token"
session.Values["refresh_token"] = "mock-refresh-token"
// Encode the session to a cookie
rec := httptest.NewRecorder()
session.Save(req, rec)
// Copy the Set-Cookie header to the request as a Cookie header
for _, cookie := range rec.Result().Cookies() {
req.AddCookie(cookie)
}
}
func PerformHttpRequest(method string, path string, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
return PerformHttpRequestWithClaims(method, path, DefaultTestClaims(), requestParams...)
}
func PerformHttpRequestWithClaims(method string, path string, claims TestClaims, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
var body map[string]interface{}
headers := map[string]string{}
query := map[string]string{}
appConfig := CreateTestConfig()
srv := CreateHttpServer(appConfig)
for i, arg := range requestParams {
switch i {
case 0:
body = arg
case 1:
for header, value := range arg {
headers[header] = fmt.Sprintf("%v", value)
}
case 2:
for key, value := range arg {
query[key] = fmt.Sprintf("%v", value)
}
default:
panic("Unknown argument")
}
}
req := httptest.NewRequest(method, path, CreateRequestBodyFromStruct(body))
q := req.URL.Query()
for key, value := range query {
q.Add(key, value)
}
req.URL.RawQuery = q.Encode()
for header, value := range headers {
req.Header.Set(header, value)
}
// Add mock session cookie for authentication
addMockSessionCookie(req, appConfig)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}

View File

@@ -0,0 +1,53 @@
package test
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"clintonambulance.com/calculate_negative_points/internal/config"
"github.com/gorilla/sessions"
"github.com/onsi/ginkgo/v2"
)
func CreateTestConfig() *config.ApplicationConfig {
appSecret := "12345678901234567890123456789012"
appSecretBlock := "09876543210987654321098765431098"
basepath := os.Getenv("APP_MIGRATION_DIRECTORY")
if basepath == "" {
_, b, _, _ := runtime.Caller(0)
basepath = filepath.Dir(b)
}
return &config.ApplicationConfig{
AppSecret: config.SecretFromValue(appSecret),
AppSecretBlock: config.SecretFromValue(appSecretBlock),
CookieStore: sessions.NewCookieStore([]byte(appSecret)),
Environment: "test",
Listen: "127.0.0.1:3000",
MatchThreshold: 3,
SessionName: "calculate-negative-points-test",
NocoDBConfig: config.NocoDBConfig{
ApiToken: config.SecretFromValue("1234567890"),
BaseUrl: "https://example.com",
EmployeesTableId: "0987654321",
InfractionsTableId: "2468013579",
NegativeInfractionId: 1,
NoPointsViewId: "1357924680",
},
}
}
func UnmarshalBody[K comparable, V any](rec *httptest.ResponseRecorder) map[K]V {
var body map[K]V
err := json.NewDecoder(rec.Body).Decode(&body)
if err != nil {
ginkgo.Fail("Could not decode response")
}
return body
}

28
internal/types/generic.go Normal file
View File

@@ -0,0 +1,28 @@
package types
// PaginationRequest is a standard pagination input for API requests
type PaginationRequest struct {
Page int `json:"page" query:"page" default:"1" minimum:"1"`
PageSize int `json:"page_size" query:"page_size" default:"20" minimum:"1" maximum:"100"`
}
// PaginationResponse is a standard pagination output for API responses
type PaginationResponse struct {
Page int `json:"page" required:"true"`
PageSize int `json:"page_size" required:"true"`
Total int `json:"total" required:"true"`
TotalPages int `json:"total_pages" required:"true"`
}
type PaginatedIndexResponse[T any] struct {
Items []T `json:"items" required:"true" nullable:"false"`
Pagination PaginationResponse `json:"pagination" required:"true" nullable:"false"`
}
type IndexResponse[T any] struct {
Items []T `json:"items" required:"true" nullable:"false"`
}
type ShowResponse[T any] struct {
Item T `json:"item" required:"true" nullable:"false"`
}

View File

@@ -0,0 +1,9 @@
package types
type UiPayrollCategory struct {
Name string `json:"name" required:"true"`
Rate float64 `json:"rate" required:"true" format:"double"`
Id int `json:"id" required:"true"`
}
type UiPayrollCategoriesResponse = IndexResponse[UiPayrollCategory]

View File

@@ -0,0 +1,11 @@
package types
type UiPayrollEntry struct {
Category UiPayrollCategory `json:"category" required:"true" nullable:"false"`
Date string `json:"date" format:"iso8601"`
Description string `json:"description"`
Employee UiUser `json:"employee" required:"true" nullable:"false"`
Quantity float64 `json:"quantity" format:"double" required:"true"`
}
type UiPayrollEntriesResponse = PaginatedIndexResponse[UiPayrollEntry]

24
internal/types/users.go Normal file
View File

@@ -0,0 +1,24 @@
package types
import "clintonambulance.com/calculate_negative_points/internal/idms"
type UiUser struct {
FirstName string `json:"first_name"`
Groups []string `json:"groups,omitempty" required:"false"`
Id string `json:"id" required:"true" nullable:"false"`
LastName string `json:"last_name"`
Name string `json:"name" required:"true" nullable:"false"`
}
type UiUsersResponse = IndexResponse[UiUser]
type UiUserResponse = ShowResponse[UiUser]
func UiUserFromIdmsUser(u idms.IdmsUser) UiUser {
return UiUser{
FirstName: u.FirstName(),
Groups: u.Groups,
Id: string(u.Attributes.EmployeeId),
LastName: u.LastName(),
Name: u.Name,
}
}

View File

@@ -0,0 +1,13 @@
package utils
import "strings"
func ConvertNameFormat(name string) string {
parts := strings.Split(name, ",")
if len(parts) == 2 {
lastName := strings.TrimSpace(parts[0])
firstName := strings.TrimSpace(parts[1])
return firstName + " " + lastName
}
return name
}

43
internal/utils/error.go Normal file
View File

@@ -0,0 +1,43 @@
package utils
import (
"net/http"
"github.com/swaggest/rest"
)
// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema.
type CustomApplicationError interface {
rest.ErrWithHTTPStatus
CustomApplicationError()
}
type ApplicationError struct {
Message string `json:"message"`
code int
}
var (
_ CustomApplicationError = ApplicationError{}
)
func NewApplicationError(message string, code int) ApplicationError {
return ApplicationError{
Message: message,
code: code,
}
}
func (e ApplicationError) Error() string {
return e.Message
}
func (e ApplicationError) HTTPStatus() int {
return e.code
}
func (e ApplicationError) CustomApplicationError() {}
func ValidationError(message string) ApplicationError {
return NewApplicationError(message, http.StatusUnprocessableEntity)
}

View File

@@ -0,0 +1,20 @@
package utils
import "time"
func FirstDayOfMonth() string {
// Load America/Detroit timezone
loc, err := time.LoadLocation("America/Detroit")
if err != nil {
panic(err)
}
// Get current time in Detroit timezone
now := time.Now().In(loc)
// Create midnight of the first day of the current month
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
// Return ISO8601 formatted string
return firstDay.Format(time.RFC3339)
}

View File

@@ -0,0 +1,34 @@
package utils
import "strings"
func LevenshteinDistance(s1, s2 string) int {
s1 = strings.ToLower(s1)
s2 = strings.ToLower(s2)
len1 := len(s1)
len2 := len(s2)
matrix := make([][]int, len1+1)
for i := range matrix {
matrix[i] = make([]int, len2+1)
matrix[i][0] = i
}
for j := range matrix[0] {
matrix[0][j] = j
}
for i := 1; i <= len1; i++ {
for j := 1; j <= len2; j++ {
cost := 0
if s1[i-1] != s2[j-1] {
cost = 1
}
matrix[i][j] = min(
matrix[i-1][j]+1,
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
)
}
}
return matrix[len1][len2]
}

19
internal/utils/logging.go Normal file
View File

@@ -0,0 +1,19 @@
package utils
import (
"fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) {
err := dbOp()
logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation)
if err != nil {
logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName)
}
return logMsg, err
}

12
internal/utils/must.go Normal file
View File

@@ -0,0 +1,12 @@
package utils
import "go.uber.org/zap"
func Must[T any](res T, err error) func(logger *zap.Logger) T {
return func(logger *zap.Logger) T {
if err != nil {
logger.Fatal("Fatal error", zap.Error(err))
}
return res
}
}

View File

@@ -0,0 +1,23 @@
package utils
import (
"regexp"
"strings"
"unicode"
)
func NormalizeName(name string) string {
// Remove extra whitespace
name = strings.TrimSpace(name)
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
// Remove common punctuation but keep hyphens
name = strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
return unicode.ToLower(r)
}
return -1
}, name)
return name
}

View File

@@ -0,0 +1,105 @@
package utils
import (
"fmt"
"io"
"mime/multipart"
"regexp"
"strconv"
"strings"
"github.com/samber/lo"
)
type Shift struct {
EarningCode string `json:"earning_code"`
Description string `json:"description"`
Hours float64 `json:"hours"`
}
func (s Shift) CountsAsWorked() bool {
return strings.ToLower(s.EarningCode) == "reg"
}
type Employee struct {
Name string `json:"name"`
Shifts []Shift `json:"shifts"`
}
func (e Employee) Worked() bool {
return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() })
}
func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) {
f, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to open XLS file: %w", err)
}
defer f.Close()
content, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read XLS file: %w", err)
}
return ParseXLSContent(content)
}
// ParseXLSContent parses the HTML content of an XLS file and returns a slice
// of employees with their shifts. The XLS files are HTML tables where:
// - Column A: employee name (td with class="smallbold" and valign=top)
// - Column B: inner table containing shift rows
// - Within the inner table, rows with colspan are category headers (skipped)
// - Shift rows have: spacer td, earning code/description td (class="smalltext"),
// and hours td
func ParseXLSContent(content []byte) ([]Employee, error) {
html := string(content)
// Split by employee rows in the outer table. Each employee row contains
// a name cell (column A) followed by a cell with an inner table (column B).
employeePattern := regexp.MustCompile(
`<td\s+valign=top\s+class="smallbold">([^<]+)</td>\s*<td>(.*?)</table></td>`,
)
employeeMatches := employeePattern.FindAllStringSubmatch(html, -1)
// Pattern for shift data rows: spacer td + earning code td + hours td
// These are rows where column C has class="smalltext" (not a colspan header)
shiftPattern := regexp.MustCompile(
`<tr><td width=25></td><td class=smalltext>([^<]+)</td><td[^>]*>([^<]+)</td></tr>`,
)
employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee {
name := strings.TrimSpace(empMatch[1])
innerTable := empMatch[2]
shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1)
shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) {
codeAndDesc := strings.TrimSpace(sm[1])
hoursStr := strings.TrimSpace(sm[2])
parts := strings.SplitN(codeAndDesc, " - ", 2)
if len(parts) != 2 {
return Shift{}, false
}
hours, err := strconv.ParseFloat(hoursStr, 64)
if err != nil {
return Shift{}, false
}
return Shift{
EarningCode: strings.TrimSpace(parts[0]),
Description: strings.TrimSpace(parts[1]),
Hours: hours,
}, true
})
return Employee{
Name: name,
Shifts: shifts,
}
})
return employees, nil
}

View File

@@ -0,0 +1,77 @@
package utils_test
import (
"os"
"clintonambulance.com/calculate_negative_points/internal/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("ParseXLSContent", func() {
It("parses employees and their shifts from the test XLS file", func() {
content, err := os.ReadFile("testdata/test.xls")
Expect(err).NotTo(HaveOccurred())
employees, err := utils.ParseXLSContent(content)
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(HaveLen(2))
By("parsing the first employee")
Expect(employees[0].Name).To(Equal("Howe, Eugene"))
Expect(employees[0].Worked()).To(BeTrue())
Expect(employees[0].Shifts).To(HaveLen(2))
Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{
EarningCode: "MEET",
Description: "Meetings",
Hours: 1.75,
}))
Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse())
Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{
EarningCode: "Reg",
Description: "Regular Hours",
Hours: 125.75,
}))
Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue())
By("parsing the second employee with multiple shift categories")
Expect(employees[1].Name).To(Equal("User, Test"))
Expect(employees[1].Shifts).To(HaveLen(4))
Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{
EarningCode: "MEET",
Description: "Meetings",
Hours: 2.00,
}))
Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{
EarningCode: "Reg",
Description: "Regular Hours",
Hours: 134.00,
}))
Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{
EarningCode: "Holiday",
Description: "Holiday Hours",
Hours: 8.00,
}))
Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{
EarningCode: "PTO",
Description: "Paid Time Off",
Hours: 12.00,
}))
})
It("skips category header rows (colspan rows)", func() {
content, err := os.ReadFile("testdata/test.xls")
Expect(err).NotTo(HaveOccurred())
employees, err := utils.ParseXLSContent(content)
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(HaveLen(2))
})
It("returns an empty slice for empty content", func() {
employees, err := utils.ParseXLSContent([]byte(""))
Expect(err).NotTo(HaveOccurred())
Expect(employees).To(BeEmpty())
})
})

View File

@@ -0,0 +1,14 @@
package utils
import (
"crypto/rand"
"encoding/hex"
)
func RandomString(n int) (string, error) {
bytes := make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,11 @@
package schemautils
type OpenfeatureFlag struct {
Name string `json:"name" required:"true" description:"Name of the flag"`
Id string `json:"id" required:"true" description:"UUID of the flag"`
Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"`
Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"`
Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"`
Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"`
Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"`
}

View File

@@ -0,0 +1,24 @@
package utils
import "strings"
func ToTitleCase(name string) string {
words := strings.Fields(name)
for i, word := range words {
if len(word) > 0 {
// Handle hyphenated names
if strings.Contains(word, "-") {
parts := strings.Split(word, "-")
for j, part := range parts {
if len(part) > 0 {
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
}
}
words[i] = strings.Join(parts, "-")
} else {
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
}
}
}
return strings.Join(words, " ")
}

View File

@@ -0,0 +1,13 @@
package utils_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestUtils(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Utils Suite")
}

37
internal/views/web.html Normal file
View File

@@ -0,0 +1,37 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calculate Negative Points</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
/>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
{{ if .isDev }}
<script type="module">
import RefreshRuntime from "{{.baseAddress}}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="{{.baseAddress}}/@vite/client"></script>
<script type="module" src="{{.jsFileAddress}}"></script>
{{ else }}
<script type="module" src="/{{.jsFileAddress}}"></script>
{{ range .css }}
<link rel="stylesheet" href="/{{.}}" />
{{ end }}
{{ end }}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More