diff --git a/.air.toml b/.air.toml
new file mode 100644
index 0000000..9cb4d3a
--- /dev/null
+++ b/.air.toml
@@ -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
diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml
new file mode 100644
index 0000000..4d7cd18
--- /dev/null
+++ b/.gitea/workflows/build.yaml
@@ -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"
diff --git a/.gitignore b/.gitignore
index 1dfff1b..b78f757 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,6 @@
*.log
build/*.txt
*.txt
+*.xls
-/tmp/
-inputs/
+/tmp/
\ No newline at end of file
diff --git a/.tool-versions b/.tool-versions
index f645bcd..d5b2e8c 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1,3 @@
golang 1.25.6
+golangci-lint 2.8.0
+bun 1.3.8
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..c98f086
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "makefile.configureOnOpen": false
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3779526
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0d7f239
--- /dev/null
+++ b/Makefile
@@ -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
\ No newline at end of file
diff --git a/cmd/app.go b/cmd/app.go
new file mode 100644
index 0000000..69b8e40
--- /dev/null
+++ b/cmd/app.go
@@ -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)
+ })
+}
diff --git a/cmd/generate-api-docs/main.go b/cmd/generate-api-docs/main.go
new file mode 100644
index 0000000..7dcfe06
--- /dev/null
+++ b/cmd/generate-api-docs/main.go
@@ -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))
+}
diff --git a/config/credentials/dev.enc b/config/credentials/dev.enc
new file mode 120000
index 0000000..4dc4780
--- /dev/null
+++ b/config/credentials/dev.enc
@@ -0,0 +1 @@
+development.enc
\ No newline at end of file
diff --git a/config/credentials/dev.key b/config/credentials/dev.key
new file mode 120000
index 0000000..ef53c05
--- /dev/null
+++ b/config/credentials/dev.key
@@ -0,0 +1 @@
+development.key
\ No newline at end of file
diff --git a/config/credentials/development.enc b/config/credentials/development.enc
new file mode 100644
index 0000000..b9543a1
--- /dev/null
+++ b/config/credentials/development.enc
@@ -0,0 +1 @@
+43632cedb614ccfcd5fc7362289eb48103bb5d36e9293299bef71362f2ac0b4ae185b9911e0d9d8c50ade436c7f688991719a1cd6b4339aaf91a9ad3c5646b15e5f4f59f127bf9f038623af497d703dce6e38576bda2f41df57c66845bca1cd84430cbdad6d8c2c55564cd01926dbb368bb6328047e1269626478563783add3134618c10fba4735639bc5d0f517192766a843ef04288ea9f7a4f20a2df2f24eaad562f7762b28546e9ecc9fcb462db8eedc2dcc9981952a1a6b9d4654eb0ced0dd5b662904647b5e64163a40289bb780db5333e0154319d0cb67b2aec127bfc1e570b34410078ceb605cc7fb668cd082e326d68d7eb1177ad50058e0053eeefd14c6a59809d5934241e9cee8392b5951f666ff416088731b1ba88bb648dd48e87ddd7585c3a5e4e5e46431c9b0137a8ac2bdb0512fa4ecfc1be3bc59d80682bf6d85b872f2c32740e5cff94614b067f09d9a987d0dcf4b0478d6286b9da1084af7ed6d1dc428047ac4580c216a363c1e10d9a11cb5068f42d5bbd63014ceb83009228a4d41796ceb1432298f5b273dbca63e2f5a0f7d3572b1374cdf85d28a71706aa553d1a2b31aabbcad271c6be6d7d9d3b4cc0693314e08c8789678c6f086984f4f9d3cc7a86c5020a35498944693b7c6d1929b40e4b962f71b051b2f732657d3e8544a789094c214482ae8c823612f17e450e3c463c52335ffa900f0ab7a8cc0abd9c38fc5d9c997b747==--==66d2a6c19fe7d7a40ff1417d
\ No newline at end of file
diff --git a/config/credentials/development.key b/config/credentials/development.key
new file mode 100644
index 0000000..865f542
--- /dev/null
+++ b/config/credentials/development.key
@@ -0,0 +1 @@
+f7e00100b7db4e78aaf5c622271476dc31e47478c49c346d7d9e9ed2a9ee0289
\ No newline at end of file
diff --git a/config/credentials/production.enc b/config/credentials/production.enc
new file mode 120000
index 0000000..4dc4780
--- /dev/null
+++ b/config/credentials/production.enc
@@ -0,0 +1 @@
+development.enc
\ No newline at end of file
diff --git a/config/credentials/production.key b/config/credentials/production.key
new file mode 120000
index 0000000..ef53c05
--- /dev/null
+++ b/config/credentials/production.key
@@ -0,0 +1 @@
+development.key
\ No newline at end of file
diff --git a/config/credentials/testEnvironment.enc b/config/credentials/testEnvironment.enc
new file mode 100644
index 0000000..46e2e94
--- /dev/null
+++ b/config/credentials/testEnvironment.enc
@@ -0,0 +1 @@
+9f545b6e36205feadbbc6b788963c4efa3fb2179c9432ca4a14c40b4f2a06b2b0a086cbd987db98e55243f56158702e8974a7e1323c0694c2b572e77f468112dc675057d11b041f042cf310a2548c8f22b927f8b3da42e7ba326f15f4bd0a69b6541192acd09b79d47effbfad3c0639d067258684743e727342bbe78a036292b56436a50513b6a630b6e8321d6862bbd16e015e457ff8b28d1a44de2aca2f9cbc42ba0b19bfbe56106860222fc5cd58e0ab8e42d==--==cd1cef336de7155de2fbd0fc
\ No newline at end of file
diff --git a/config/credentials/testEnvironment.key b/config/credentials/testEnvironment.key
new file mode 100644
index 0000000..662c926
--- /dev/null
+++ b/config/credentials/testEnvironment.key
@@ -0,0 +1 @@
+58c2f85a0c5b7ee2a9d5b96773d06700dd0c40a0f6a09f5e33fa5c0ce85a4bf0
\ No newline at end of file
diff --git a/config/development.local.yml b/config/development.local.yml
new file mode 100644
index 0000000..21c9b24
--- /dev/null
+++ b/config/development.local.yml
@@ -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"
\ No newline at end of file
diff --git a/config/settings.yml b/config/settings.yml
new file mode 100644
index 0000000..4bb47ed
--- /dev/null
+++ b/config/settings.yml
@@ -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
\ No newline at end of file
diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml
new file mode 100644
index 0000000..426ee7b
--- /dev/null
+++ b/docs/openapi/api.yaml
@@ -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
diff --git a/find_overlaps.go b/find_overlaps.go
deleted file mode 100644
index d8b6eff..0000000
--- a/find_overlaps.go
+++ /dev/null
@@ -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:
NAME |
- pattern := regexp.MustCompile(`]*class="smallbold"[^>]*>([^<]+) | `)
- 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))
-}
diff --git a/frontend/.env b/frontend/.env
new file mode 120000
index 0000000..4a82335
--- /dev/null
+++ b/frontend/.env
@@ -0,0 +1 @@
+../.env
\ No newline at end of file
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -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?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..da98444
--- /dev/null
+++ b/frontend/README.md
@@ -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,
+ },
+})
+```
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..092408a
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -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 },
+ ],
+ },
+ },
+)
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..628d332
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..a6c6409
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/frontend/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/api/generated/schema.ts b/frontend/src/api/generated/schema.ts
new file mode 100644
index 0000000..05ed46e
--- /dev/null
+++ b/frontend/src/api/generated/schema.ts
@@ -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;
+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;
+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;
+ };
+ };
+ };
+ };
+ };
+}
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..49d7332
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -0,0 +1,3 @@
+export * from "./request";
+export * from "./users";
+export * from "./process";
diff --git a/frontend/src/api/process.ts b/frontend/src/api/process.ts
new file mode 100644
index 0000000..37d413d
--- /dev/null
+++ b/frontend/src/api/process.ts
@@ -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("/api/process", formData);
+};
diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts
new file mode 100644
index 0000000..b1a38a6
--- /dev/null
+++ b/frontend/src/api/request.ts
@@ -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 (axiosFn: () => AxiosPromise) => {
+ 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 (url: string): Promise => {
+ const { data } = await axios.get(url, {
+ headers: jsonHeaders(),
+ withCredentials: true,
+ });
+ return data;
+ },
+ get: async (request: GetRequest): Promise => {
+ const params =
+ request.pagination === true
+ ? { page: request.page, page_size: request.page_size }
+ : undefined;
+ const { data } = await withUnauthorizedRedirect(() =>
+ axios.get(request.url, {
+ headers: jsonHeaders(),
+ params,
+ withCredentials: true,
+ }),
+ );
+ return data;
+ },
+ put: async (url: string, body: T | null): Promise => {
+ const { data } = await withUnauthorizedRedirect(() => {
+ return axios.put(url, body, {
+ headers: jsonHeaders(),
+ withCredentials: true,
+ });
+ });
+ return data;
+ },
+ post: async (url: string, body: T): Promise => {
+ const { data } = await withUnauthorizedRedirect(() => {
+ return axios.post(url, body, {
+ headers: jsonHeaders(),
+ withCredentials: true,
+ });
+ });
+ return data;
+ },
+ delete: async (url: string): Promise => {
+ console.log(url);
+ const { data } = await withUnauthorizedRedirect(() => {
+ return axios.delete(url, {
+ headers: jsonHeaders(),
+ withCredentials: true,
+ });
+ });
+ return data;
+ },
+ postFormData: async (url: string, body: FormData): Promise => {
+ const { data } = await withUnauthorizedRedirect(() => {
+ return axios.post(url, body, {
+ headers: { Accept: "application/json" },
+ withCredentials: true,
+ });
+ });
+ return data;
+ },
+ download: async (url: string, body: T) => {
+ const response = await withUnauthorizedRedirect(() => {
+ return axios.post(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();
+ },
+};
diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts
new file mode 100644
index 0000000..01cb76d
--- /dev/null
+++ b/frontend/src/api/users.ts
@@ -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(
+ "/api/users/current"
+ );
+};
+
+export const logoutUser = async () => {
+ return await request.delete("/api/users/current");
+};
diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png
new file mode 100644
index 0000000..0473bc9
Binary files /dev/null and b/frontend/src/assets/logo.png differ
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
new file mode 100644
index 0000000..a718b51
--- /dev/null
+++ b/frontend/src/components/Navbar.tsx
@@ -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 (
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/ProtectedWrapper.tsx b/frontend/src/components/ProtectedWrapper.tsx
new file mode 100644
index 0000000..34fa0e1
--- /dev/null
+++ b/frontend/src/components/ProtectedWrapper.tsx
@@ -0,0 +1,20 @@
+import { useRoles } from "@/hooks";
+import { every } from "lodash";
+
+type ProtectedWrapperProps = {
+ requiredRoles: string[];
+ children: React.ReactNode;
+};
+
+export const ProtectedWrapper: React.FC = ({
+ requiredRoles,
+ children,
+}) => {
+ const roles = useRoles();
+
+ if (!every(requiredRoles, (r) => roles.includes(r))) {
+ return <>>;
+ }
+
+ return <>{children}>;
+};
diff --git a/frontend/src/components/SidePanel/SidePanel.tsx b/frontend/src/components/SidePanel/SidePanel.tsx
new file mode 100644
index 0000000..eefb2ad
--- /dev/null
+++ b/frontend/src/components/SidePanel/SidePanel.tsx
@@ -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 = ({
+ children,
+ isVisible,
+ onClose,
+ style = {},
+ title,
+}) => {
+ const { portalRoot, createPortal } = usePortal();
+
+ return createPortal(
+
+ {isVisible && (
+
+
+ {title}
+
+
+
+
+ {children}
+
+
+
+
+ )}
+ ,
+ portalRoot
+ );
+};
diff --git a/frontend/src/components/SidePanel/index.ts b/frontend/src/components/SidePanel/index.ts
new file mode 100644
index 0000000..01efecd
--- /dev/null
+++ b/frontend/src/components/SidePanel/index.ts
@@ -0,0 +1 @@
+export * from "./SidePanel";
diff --git a/frontend/src/components/SidePanel/utils.ts b/frontend/src/components/SidePanel/utils.ts
new file mode 100644
index 0000000..c6408d3
--- /dev/null
+++ b/frontend/src/components/SidePanel/utils.ts
@@ -0,0 +1 @@
+export const SIDEPANEL_ANIMATION_TIME = 400 as const;
diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts
new file mode 100644
index 0000000..4af75ea
--- /dev/null
+++ b/frontend/src/components/index.ts
@@ -0,0 +1,3 @@
+export * from "./Navbar";
+export * from "./ProtectedWrapper";
+export * from "./SidePanel";
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
new file mode 100644
index 0000000..a70c8a9
--- /dev/null
+++ b/frontend/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from "./useUser";
+export * from "./usePortal";
diff --git a/frontend/src/hooks/usePortal.ts b/frontend/src/hooks/usePortal.ts
new file mode 100644
index 0000000..3ecad8a
--- /dev/null
+++ b/frontend/src/hooks/usePortal.ts
@@ -0,0 +1,24 @@
+import { useRef, useEffect } from "react";
+import { createPortal } from "react-dom";
+
+export const usePortal = () => {
+ const ref = useRef(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,
+ };
+};
diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts
new file mode 100644
index 0000000..a45116a
--- /dev/null
+++ b/frontend/src/hooks/useUser.ts
@@ -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;
+};
diff --git a/frontend/src/main.scss b/frontend/src/main.scss
new file mode 100644
index 0000000..f2db1bb
--- /dev/null
+++ b/frontend/src/main.scss
@@ -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%;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..35c8448
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+);
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
new file mode 100644
index 0000000..a34af05
--- /dev/null
+++ b/frontend/src/routeTree.gen.ts
@@ -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()
diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx
new file mode 100644
index 0000000..732af8d
--- /dev/null
+++ b/frontend/src/routes/__root.tsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+ },
+ loader: getCurrentUser,
+ errorComponent: () => (
+ <>
+
+ Login to continue
+
+ >
+ ),
+});
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx
new file mode 100644
index 0000000..93e3657
--- /dev/null
+++ b/frontend/src/routes/index.tsx
@@ -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(null);
+ const [status, setStatus] = useState<
+ "idle" | "uploading" | "success" | "error"
+ >("idle");
+ const [errorMessage, setErrorMessage] = useState("");
+ const [employees, setEmployees] = useState([]);
+
+ 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 (
+
+
Process Negative Points
+
+ {status === "success" && (
+
+
File processed successfully.
+ {employees.length > 0 && (
+ <>
+
Employees processed ({employees.length}):
+
+ {employees.map((name) => (
+ - {name}
+ ))}
+
+ >
+ )}
+
+ )}
+ {status === "error" && (
+
{errorMessage}
+ )}
+
+ );
+};
+
+export const Route = createFileRoute("/")({
+ component: Index,
+});
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..f2b0eb5
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -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" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..0dfc44e
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -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",
+ },
+});
diff --git a/go.mod b/go.mod
index 248b742..6f5487a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,7 +1,72 @@
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
+)
diff --git a/go.sum b/go.sum
index f1fa120..7a95d69 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,188 @@
-github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
-github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
+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=
diff --git a/internal/api/middleware/add_request_id.go b/internal/api/middleware/add_request_id.go
new file mode 100644
index 0000000..6b58b00
--- /dev/null
+++ b/internal/api/middleware/add_request_id.go
@@ -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)
+}
diff --git a/internal/api/middleware/current_user.go b/internal/api/middleware/current_user.go
new file mode 100644
index 0000000..2f1788c
--- /dev/null
+++ b/internal/api/middleware/current_user.go
@@ -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
+}
diff --git a/internal/api/middleware/error.go b/internal/api/middleware/error.go
new file mode 100644
index 0000000..3c8181f
--- /dev/null
+++ b/internal/api/middleware/error.go
@@ -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
+}
diff --git a/internal/api/middleware/jwt_middleware.go b/internal/api/middleware/jwt_middleware.go
new file mode 100644
index 0000000..b39da28
--- /dev/null
+++ b/internal/api/middleware/jwt_middleware.go
@@ -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
+}
diff --git a/internal/api/middleware/logging.go b/internal/api/middleware/logging.go
new file mode 100644
index 0000000..1c000a4
--- /dev/null
+++ b/internal/api/middleware/logging.go
@@ -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)
+ }
+}
diff --git a/internal/api/middleware/logout.go b/internal/api/middleware/logout.go
new file mode 100644
index 0000000..7c674b5
--- /dev/null
+++ b/internal/api/middleware/logout.go
@@ -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)
+ }
+}
diff --git a/internal/api/middleware/oidc.go b/internal/api/middleware/oidc.go
new file mode 100644
index 0000000..7151bac
--- /dev/null
+++ b/internal/api/middleware/oidc.go
@@ -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
+}
diff --git a/internal/api/middleware/pagination.go b/internal/api/middleware/pagination.go
new file mode 100644
index 0000000..454d112
--- /dev/null
+++ b/internal/api/middleware/pagination.go
@@ -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)
+ }
+}
diff --git a/internal/api/requests/module.go b/internal/api/requests/module.go
new file mode 100644
index 0000000..25a48ad
--- /dev/null
+++ b/internal/api/requests/module.go
@@ -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()))
+ })
+}
diff --git a/internal/api/requests/negative_points_processor/process_negative_points.go b/internal/api/requests/negative_points_processor/process_negative_points.go
new file mode 100644
index 0000000..63b312b
--- /dev/null
+++ b/internal/api/requests/negative_points_processor/process_negative_points.go
@@ -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
+}
diff --git a/internal/api/requests/users/current.go b/internal/api/requests/users/current.go
new file mode 100644
index 0000000..339feeb
--- /dev/null
+++ b/internal/api/requests/users/current.go
@@ -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
+}
diff --git a/internal/api/requests/users/logout.go b/internal/api/requests/users/logout.go
new file mode 100644
index 0000000..aade8b8
--- /dev/null
+++ b/internal/api/requests/users/logout.go
@@ -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
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..39736b6
--- /dev/null
+++ b/internal/config/config.go
@@ -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
+}
diff --git a/internal/config/config_suite_test.go b/internal/config/config_suite_test.go
new file mode 100644
index 0000000..c6e29ba
--- /dev/null
+++ b/internal/config/config_suite_test.go
@@ -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")
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..d270f9e
--- /dev/null
+++ b/internal/config/config_test.go
@@ -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"))
+ })
+})
diff --git a/internal/config/logging.go b/internal/config/logging.go
new file mode 100644
index 0000000..1b6d1d7
--- /dev/null
+++ b/internal/config/logging.go
@@ -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
+}
diff --git a/internal/config/secret.go b/internal/config/secret.go
new file mode 100644
index 0000000..8702199
--- /dev/null
+++ b/internal/config/secret.go
@@ -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
+ }
+ }
+}
diff --git a/internal/config/testdata/DATABASE_SECRET.sample b/internal/config/testdata/DATABASE_SECRET.sample
new file mode 100644
index 0000000..b79bbef
--- /dev/null
+++ b/internal/config/testdata/DATABASE_SECRET.sample
@@ -0,0 +1 @@
+fakesecret
\ No newline at end of file
diff --git a/internal/config/testdata/settings.test.yml b/internal/config/testdata/settings.test.yml
new file mode 100644
index 0000000..b866a2c
--- /dev/null
+++ b/internal/config/testdata/settings.test.yml
@@ -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"
\ No newline at end of file
diff --git a/internal/config/version.go b/internal/config/version.go
new file mode 100644
index 0000000..b0f6f49
--- /dev/null
+++ b/internal/config/version.go
@@ -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,
+ }
+}
diff --git a/internal/idms/request.go b/internal/idms/request.go
new file mode 100644
index 0000000..b79c171
--- /dev/null
+++ b/internal/idms/request.go
@@ -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
+}
diff --git a/internal/idms/users.go b/internal/idms/users.go
new file mode 100644
index 0000000..75f39fa
--- /dev/null
+++ b/internal/idms/users.go
@@ -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
+}
diff --git a/internal/models/models_suite_test.go b/internal/models/models_suite_test.go
new file mode 100644
index 0000000..4be06f1
--- /dev/null
+++ b/internal/models/models_suite_test.go
@@ -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")
+}
diff --git a/internal/nocodb/request.go b/internal/nocodb/request.go
new file mode 100644
index 0000000..3c937de
--- /dev/null
+++ b/internal/nocodb/request.go
@@ -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
+}
diff --git a/internal/server/http.go b/internal/server/http.go
new file mode 100644
index 0000000..59ecfe7
--- /dev/null
+++ b/internal/server/http.go
@@ -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"
+}
diff --git a/internal/server/http_test.go b/internal/server/http_test.go
new file mode 100644
index 0000000..d02a5ce
--- /dev/null
+++ b/internal/server/http_test.go
@@ -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))
+ })
+})
diff --git a/internal/test/http_helper.go b/internal/test/http_helper.go
new file mode 100644
index 0000000..40f8374
--- /dev/null
+++ b/internal/test/http_helper.go
@@ -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
+}
diff --git a/internal/test/test_helper.go b/internal/test/test_helper.go
new file mode 100644
index 0000000..7aff0a8
--- /dev/null
+++ b/internal/test/test_helper.go
@@ -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
+}
diff --git a/internal/types/generic.go b/internal/types/generic.go
new file mode 100644
index 0000000..90bcbae
--- /dev/null
+++ b/internal/types/generic.go
@@ -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"`
+}
diff --git a/internal/types/payroll_categories.go b/internal/types/payroll_categories.go
new file mode 100644
index 0000000..d74d528
--- /dev/null
+++ b/internal/types/payroll_categories.go
@@ -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]
diff --git a/internal/types/payroll_entries.go b/internal/types/payroll_entries.go
new file mode 100644
index 0000000..ba76f90
--- /dev/null
+++ b/internal/types/payroll_entries.go
@@ -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]
diff --git a/internal/types/users.go b/internal/types/users.go
new file mode 100644
index 0000000..ca018d3
--- /dev/null
+++ b/internal/types/users.go
@@ -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,
+ }
+}
diff --git a/internal/utils/convert_name_format.go b/internal/utils/convert_name_format.go
new file mode 100644
index 0000000..cdbe7d1
--- /dev/null
+++ b/internal/utils/convert_name_format.go
@@ -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
+}
diff --git a/internal/utils/error.go b/internal/utils/error.go
new file mode 100644
index 0000000..182bd79
--- /dev/null
+++ b/internal/utils/error.go
@@ -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)
+}
diff --git a/internal/utils/first_day_of_month.go b/internal/utils/first_day_of_month.go
new file mode 100644
index 0000000..db766a5
--- /dev/null
+++ b/internal/utils/first_day_of_month.go
@@ -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)
+}
diff --git a/internal/utils/levenshtein_distance.go b/internal/utils/levenshtein_distance.go
new file mode 100644
index 0000000..9d12257
--- /dev/null
+++ b/internal/utils/levenshtein_distance.go
@@ -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]
+}
diff --git a/internal/utils/logging.go b/internal/utils/logging.go
new file mode 100644
index 0000000..1070271
--- /dev/null
+++ b/internal/utils/logging.go
@@ -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
+}
diff --git a/internal/utils/must.go b/internal/utils/must.go
new file mode 100644
index 0000000..fee7405
--- /dev/null
+++ b/internal/utils/must.go
@@ -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
+ }
+}
diff --git a/internal/utils/normalize_name.go b/internal/utils/normalize_name.go
new file mode 100644
index 0000000..3ea1a7a
--- /dev/null
+++ b/internal/utils/normalize_name.go
@@ -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
+}
diff --git a/internal/utils/parse_xls_file.go b/internal/utils/parse_xls_file.go
new file mode 100644
index 0000000..02b2c71
--- /dev/null
+++ b/internal/utils/parse_xls_file.go
@@ -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(
+ `([^<]+) | \s*(.*?) | `,
+ )
+ 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(
+ ` | ([^<]+) | ]*>([^<]+) |
`,
+ )
+
+ 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
+}
diff --git a/internal/utils/parse_xls_file_test.go b/internal/utils/parse_xls_file_test.go
new file mode 100644
index 0000000..32afd75
--- /dev/null
+++ b/internal/utils/parse_xls_file_test.go
@@ -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())
+ })
+})
diff --git a/internal/utils/random_string.go b/internal/utils/random_string.go
new file mode 100644
index 0000000..ca93cae
--- /dev/null
+++ b/internal/utils/random_string.go
@@ -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
+}
diff --git a/internal/utils/schema/openfeature_flag.go b/internal/utils/schema/openfeature_flag.go
new file mode 100644
index 0000000..9c1eea2
--- /dev/null
+++ b/internal/utils/schema/openfeature_flag.go
@@ -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"`
+}
diff --git a/internal/utils/to_title_case.go b/internal/utils/to_title_case.go
new file mode 100644
index 0000000..a1c012b
--- /dev/null
+++ b/internal/utils/to_title_case.go
@@ -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, " ")
+}
diff --git a/internal/utils/utils_suite_test.go b/internal/utils/utils_suite_test.go
new file mode 100644
index 0000000..9ca82ff
--- /dev/null
+++ b/internal/utils/utils_suite_test.go
@@ -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")
+}
diff --git a/internal/views/web.html b/internal/views/web.html
new file mode 100644
index 0000000..ef47e3a
--- /dev/null
+++ b/internal/views/web.html
@@ -0,0 +1,37 @@
+
+
+
+
+ Calculate Negative Points
+
+
+
+
+
+
+
+ {{ if .isDev }}
+
+
+
+
+ {{ else }}
+
+ {{ range .css }}
+
+ {{ end }}
+ {{ end }}
+
+
+
diff --git a/internal/web/web.go b/internal/web/web.go
new file mode 100644
index 0000000..3ca3710
--- /dev/null
+++ b/internal/web/web.go
@@ -0,0 +1,160 @@
+package web
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "clintonambulance.com/calculate_negative_points/internal/config"
+ "github.com/go-chi/chi/v5"
+ "github.com/swaggest/rest/web"
+ "go.uber.org/zap"
+)
+
+type ManifestEntry struct {
+ Src string `json:"src"`
+ File string `json:"file"`
+ IsEntry bool `json:"isEntry"`
+ Css []string `json:"css"`
+}
+
+func withEntryPoint(config *config.ApplicationConfig, ep string, tmplParams map[string]interface{}) {
+
+ if config.Environment == "dev" {
+ tmplParams["jsFileAddress"] = fmt.Sprintf("%s/%s", tmplParams["baseAddress"], ep)
+ } else {
+ var parsedManifest map[string]ManifestEntry
+ manifestContent, err := os.ReadFile(filepath.Join(config.PublicPath, ".vite", "manifest.json"))
+
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ err = json.Unmarshal(manifestContent, &parsedManifest)
+
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ tmplParams["jsFileAddress"] = parsedManifest[ep].File
+ tmplParams["css"] = parsedManifest[ep].Css
+ }
+
+}
+
+func MountWebEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
+ rootResponder := func(w http.ResponseWriter, r *http.Request) {
+ tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "web.html"))
+
+ tmplParams := map[string]interface{}{
+ "baseAddress": "http://localhost:5173",
+ "isDev": config.Environment == "dev",
+ "environment": config.Environment,
+ }
+
+ withEntryPoint(config, "src/main.tsx", tmplParams)
+
+ if err != nil {
+ log.Println(err)
+ http.Error(w, "Something went wrong", http.StatusInternalServerError)
+ return
+ }
+
+ if err := tmpl.Execute(w, tmplParams); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+
+ loggedOutResponder := func(w http.ResponseWriter, r *http.Request) {
+ tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "logged_out.html"))
+
+ if err != nil {
+ http.Error(w, "Something went wrong", http.StatusInternalServerError)
+ return
+ }
+
+ if err := tmpl.Execute(w, &struct{}{}); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+
+ notAuthorizedResponder := func(w http.ResponseWriter, r *http.Request) {
+ tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "auth_error.html"))
+
+ if err != nil {
+ http.Error(w, "Something went wrong", http.StatusInternalServerError)
+ return
+ }
+
+ if err := tmpl.Execute(w, &struct{}{}); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }
+
+ loginHandler := func(w http.ResponseWriter, r *http.Request) {
+ returnTo := r.URL.Query().Get("returnTo")
+ if returnTo == "" {
+ returnTo = "/" // fallback
+ }
+
+ session, _ := config.CookieStore.Get(r, config.SessionName)
+ session.Values["return_to"] = returnTo
+ session.Save(r, w)
+
+ state := "random-state" // Replace with actual CSRF protection
+ http.Redirect(w, r, config.OidcConfig.OAuth2.AuthCodeURL(state), http.StatusFound)
+ }
+
+ callbackHandler := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ code := r.URL.Query().Get("code")
+
+ token, err := config.OidcConfig.OAuth2.Exchange(ctx, code)
+ if err != nil {
+ http.Error(w, "Token exchange failed", http.StatusInternalServerError)
+ return
+ }
+
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ http.Error(w, "Missing id_token", http.StatusInternalServerError)
+ return
+ }
+
+ _, err = config.OidcConfig.Verifier.Verify(ctx, rawIDToken)
+ if err != nil {
+ http.Error(w, "Invalid ID token", http.StatusUnauthorized)
+ return
+ }
+
+ session, _ := config.CookieStore.Get(r, config.SessionName)
+
+ if refreshToken, ok := token.Extra("refresh_token").(string); ok {
+ session.Values["refresh_token"] = refreshToken
+ }
+
+ returnTo, ok := session.Values["return_to"].(string)
+ if !ok || returnTo == "" {
+ returnTo = "/"
+ }
+ session.Values["id_token"] = rawIDToken
+ session.Save(r, w)
+
+ http.Redirect(w, r, returnTo, http.StatusFound)
+ }
+
+ e.Wrapper.Get("/users/logged_out", loggedOutResponder)
+ e.Wrapper.Get("/403", notAuthorizedResponder)
+ e.Wrapper.Post("/403", notAuthorizedResponder)
+
+ e.Route("/", func(r chi.Router) {
+ r.HandleFunc("/", rootResponder)
+ r.Get("/auth/login", loginHandler)
+ r.Get("/auth/callback", callbackHandler)
+ r.MethodFunc(http.MethodGet, "/*", rootResponder)
+ })
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..49146e5
--- /dev/null
+++ b/main.go
@@ -0,0 +1,10 @@
+package main
+
+import (
+ "clintonambulance.com/calculate_negative_points/cmd"
+ "clintonambulance.com/calculate_negative_points/internal/config"
+)
+
+func main() {
+ cmd.Execute(config.NewVersion())
+}