This commit is contained in:
44
.air.toml
Normal file
44
.air.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 0
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
34
.gitea/workflows/build.yaml
Normal file
34
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Docker Build and Publish
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."docker.office.clintonambulance.com"]
|
||||||
|
- name: Log in to Docker Registry
|
||||||
|
uses: https://github.com/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: docker.office.clintonambulance.com
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: https://github.com/docker/build-push-action@v5
|
||||||
|
env:
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: "docker.office.clintonambulance.com/calculate_negative_points:${{gitea.sha}},docker.office.clintonambulance.com/calculate_negative_points:latest"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -3,6 +3,6 @@
|
|||||||
*.log
|
*.log
|
||||||
build/*.txt
|
build/*.txt
|
||||||
*.txt
|
*.txt
|
||||||
|
*.xls
|
||||||
|
|
||||||
/tmp/
|
/tmp/
|
||||||
inputs/
|
|
||||||
@@ -1 +1,3 @@
|
|||||||
golang 1.25.6
|
golang 1.25.6
|
||||||
|
golangci-lint 2.8.0
|
||||||
|
bun 1.3.8
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"makefile.configureOnOpen": false
|
||||||
|
}
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
FROM golang:1.25.6 AS gobuilder
|
||||||
|
|
||||||
|
WORKDIR /opt/app-root
|
||||||
|
|
||||||
|
COPY Makefile .tool-versions go.mod go.sum ./
|
||||||
|
RUN --mount=type=tmpfs,target=/tmp \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \
|
||||||
|
make mod
|
||||||
|
|
||||||
|
COPY . /opt/app-root/
|
||||||
|
|
||||||
|
RUN --mount=type=tmpfs,target=/tmp \
|
||||||
|
--mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \
|
||||||
|
CGO_ENABLED="0" BINDIR="/usr/local/bin" make clean; CGO_ENABLED="0" BINDIR="/usr/local/bin" make build
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.8 AS bunbuilder
|
||||||
|
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
|
||||||
|
COPY . /opt/app-root
|
||||||
|
|
||||||
|
WORKDIR /opt/app-root/frontend
|
||||||
|
|
||||||
|
RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/log \
|
||||||
|
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=calculate-negative-points-bun138-build-share-yarn-cache \
|
||||||
|
--mount=type=cache,target=/opt/app-root/frontend/node_modules,id=calculate-negative-points-bun138-build-app-node-modules \
|
||||||
|
bun install; bunx --bun vite build
|
||||||
|
|
||||||
|
FROM golang:1.25.6 AS final
|
||||||
|
|
||||||
|
WORKDIR $NON_ROOT_USER_HOME
|
||||||
|
COPY --from=bunbuilder /opt/app-root/public /opt/app-root/public
|
||||||
|
COPY --from=gobuilder /usr/local/bin/calculate_negative_points /usr/local/bin/calculate_negative_points
|
||||||
|
COPY --from=gobuilder /opt/app-root/internal/views /opt/app-root/views
|
||||||
|
COPY --from=gobuilder /opt/app-root/db/migrations /opt/app-root/migrations
|
||||||
|
COPY config/ /opt/app-root/config
|
||||||
|
|
||||||
|
ENV TZ=America/Detroit
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["/usr/local/bin/calculate_negative_points", "--listen", "0.0.0.0:3000"]
|
||||||
100
Makefile
Normal file
100
Makefile
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Ignore built-in rules, these do not apply for Go builds
|
||||||
|
MAKEFLAGS += --no-builtin-rules
|
||||||
|
|
||||||
|
# Ignore the default suffixes, in this automation script all names refer to actual tasks and files
|
||||||
|
.SUFFIXES:
|
||||||
|
|
||||||
|
.PHONY: all
|
||||||
|
all: check test build
|
||||||
|
|
||||||
|
###
|
||||||
|
### Pick the right Go SDK if several are available
|
||||||
|
###
|
||||||
|
|
||||||
|
GO_SDK_VERSION ?= $(shell \awk '/^golang [0-9]/ { print $$2 }' .tool-versions)
|
||||||
|
ifneq (,$(shell command -v go$(GO_SDK_VERSION)))
|
||||||
|
# Use the selected SDK
|
||||||
|
GOVERSION ?= go$(GO_SDK_VERSION)
|
||||||
|
else
|
||||||
|
# No SDK in the PATH, fall back on whatever version is available
|
||||||
|
GOVERSION := go
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifeq ($(OS),Darwin)
|
||||||
|
# Use the selected SDK
|
||||||
|
LINTOS ?= darwin
|
||||||
|
else
|
||||||
|
# No SDK in the PATH, fall back on whatever version is available
|
||||||
|
LINTOS := linux
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifeq ($(shell uname -p),aarch64)
|
||||||
|
COERCED_ARCH = arm64
|
||||||
|
else
|
||||||
|
COERCED_ARCH = amd64
|
||||||
|
endif
|
||||||
|
|
||||||
|
export BUILD_DATE ?= $(shell \date -u '+%FT%TZ')
|
||||||
|
export GIT_COMMIT ?= $(shell git describe --match=NeVeRmAtCh --always --abbrev=7 --dirty='*')
|
||||||
|
export APP_MAJOR_MINOR_VERSION ?= $(shell \grep -m1 -o -E '^[0-9]+\.[0-9]+' VERSION)
|
||||||
|
export APP_PATCH_VERSION ?= dev
|
||||||
|
export APP_VERSION ?= $(APP_MAJOR_MINOR_VERSION).$(APP_PATCH_VERSION)
|
||||||
|
|
||||||
|
CONFIG_PACKAGE := clintonambulance.com/calculate_negative_points/internal/config
|
||||||
|
LDFLAGS_VERSION := -X $(CONFIG_PACKAGE).release=$(APP_VERSION) -X $(CONFIG_PACKAGE).date=$(BUILD_DATE) -X $(CONFIG_PACKAGE).commit=$(GIT_COMMIT)
|
||||||
|
LDFLAGS := -ldflags "$(LDFLAGS_VERSION) $(LDFLAGS_EXTRA)"
|
||||||
|
|
||||||
|
###
|
||||||
|
### Output directory
|
||||||
|
###
|
||||||
|
|
||||||
|
BINDIR ?= $(shell pwd)/bin
|
||||||
|
$(BINDIR):
|
||||||
|
install -d $(BINDIR)
|
||||||
|
|
||||||
|
.PHONY: mod
|
||||||
|
mod:
|
||||||
|
@printf "\nRunning go mod...\n"
|
||||||
|
$(GOVERSION) mod verify
|
||||||
|
$(GOVERSION) mod download
|
||||||
|
@printf "Complete.\n"
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check: GOFMTCHECK := $(shell gofmt -s -d .)
|
||||||
|
check:
|
||||||
|
@printf "\nRunning checks...\n"
|
||||||
|
@echo - gofmt
|
||||||
|
@[[ "$(GOFMTCHECK)" == "" ]] || (echo "FAILED: gofmt failed" ; exit 1)
|
||||||
|
@echo - golangci-lint
|
||||||
|
@golangci-lint run
|
||||||
|
@printf "All checks PASSED.\n"
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@printf "\nRunning tests...\n"
|
||||||
|
ginkgo -r
|
||||||
|
@printf "Tests PASSED.\n"
|
||||||
|
|
||||||
|
test-until-fail:
|
||||||
|
@printf "\nRunning tests until something fails...\n"
|
||||||
|
ginkgo -r --until-it-fails
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean: $(BINDIR)
|
||||||
|
@printf "\nCleaning output files...\n"
|
||||||
|
rm -f $(BINDIR)/calculate_negative_points $(BINDIR)/sync_from_samsara
|
||||||
|
|
||||||
|
.PHONY: buildbins
|
||||||
|
buildbins: $(BINDIR)
|
||||||
|
@printf "\nRunning build...\n"
|
||||||
|
$(GOVERSION) build $(LDFLAGS) -trimpath -o $(BINDIR)/calculate_negative_points
|
||||||
|
@printf "Build complete.\n"
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: clean buildbins
|
||||||
|
|
||||||
|
docs/openapi/api.yaml: $(shell find . -name '*.go')
|
||||||
|
@printf "\nBuilding OpenAPI document...\n"
|
||||||
|
$(GOVERSION) run $(LDFLAGS) cmd/generate-api-docs/main.go -e testEnvironment -c internal/config/testdata/settings.test.yml
|
||||||
|
@printf "\nGenerating API types...\n"
|
||||||
|
cd frontend && bun run generate-api-types
|
||||||
58
cmd/app.go
Normal file
58
cmd/app.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/swaggest/rest/web"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Execute(version config.Version) {
|
||||||
|
logger, flushLogs := config.NewLogger(version, os.Stdout, os.Args)
|
||||||
|
defer flushLogs()
|
||||||
|
|
||||||
|
configuration := utils.Must(config.Load(version, os.Exit, os.Args, logger))(logger)
|
||||||
|
|
||||||
|
logger.Info("Application startup", zap.Any("config", configuration), zap.Any("full_version", version))
|
||||||
|
|
||||||
|
srv, _ := server.NewHttpServer(logger, version)
|
||||||
|
|
||||||
|
FileServer(srv, "/assets/", configuration.PublicPath, logger)
|
||||||
|
|
||||||
|
server.MountAllEndpoints(srv, version, configuration, logger)
|
||||||
|
|
||||||
|
srv.NotFound(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
apimiddleware.ErrorResponder(w, "Not Found", http.StatusNotFound)
|
||||||
|
})
|
||||||
|
srv.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
apimiddleware.ErrorResponder(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(configuration.Listen, srv); err != nil {
|
||||||
|
logger.Fatal("HTTP server error", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FileServer(r *web.Service, path string, rootPath string, logger *zap.Logger) {
|
||||||
|
root := http.Dir(rootPath)
|
||||||
|
if strings.ContainsAny(path, "{}*") {
|
||||||
|
panic("FileServer does not permit any URL parameters.")
|
||||||
|
}
|
||||||
|
path += "*"
|
||||||
|
|
||||||
|
r.Wrapper.Get(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
|
||||||
|
logger.Info("Serving file", zap.String("root_path", rootPath), zap.String("pathPrefix", pathPrefix), zap.String("rawPath", r.URL.Path))
|
||||||
|
fs := http.FileServer(root)
|
||||||
|
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
35
cmd/generate-api-docs/main.go
Normal file
35
cmd/generate-api-docs/main.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
version := config.NewVersion()
|
||||||
|
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment", "-c", "internal/config/testdata/settings.test.yml"})
|
||||||
|
defer flushLogs()
|
||||||
|
|
||||||
|
configuration, err := config.Load(version, os.Exit, os.Args, logger)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, reflector := server.NewHttpServer(logger, version)
|
||||||
|
server.MountAllEndpoints(srv, version, configuration, logger)
|
||||||
|
|
||||||
|
//schema, err := srv.OpenAPI.MarshalYAML()
|
||||||
|
schema, err := reflector.Spec.MarshalYAML()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
outputFile := "docs/openapi/api.yaml"
|
||||||
|
if err := os.WriteFile(outputFile, schema, 0644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
logger.Info("Generated OpenAPI docs", zap.String("output", outputFile))
|
||||||
|
}
|
||||||
1
config/credentials/dev.enc
Symbolic link
1
config/credentials/dev.enc
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
development.enc
|
||||||
1
config/credentials/dev.key
Symbolic link
1
config/credentials/dev.key
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
development.key
|
||||||
1
config/credentials/development.enc
Normal file
1
config/credentials/development.enc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
43632cedb614ccfcd5fc7362289eb48103bb5d36e9293299bef71362f2ac0b4ae185b9911e0d9d8c50ade436c7f688991719a1cd6b4339aaf91a9ad3c5646b15e5f4f59f127bf9f038623af497d703dce6e38576bda2f41df57c66845bca1cd84430cbdad6d8c2c55564cd01926dbb368bb6328047e1269626478563783add3134618c10fba4735639bc5d0f517192766a843ef04288ea9f7a4f20a2df2f24eaad562f7762b28546e9ecc9fcb462db8eedc2dcc9981952a1a6b9d4654eb0ced0dd5b662904647b5e64163a40289bb780db5333e0154319d0cb67b2aec127bfc1e570b34410078ceb605cc7fb668cd082e326d68d7eb1177ad50058e0053eeefd14c6a59809d5934241e9cee8392b5951f666ff416088731b1ba88bb648dd48e87ddd7585c3a5e4e5e46431c9b0137a8ac2bdb0512fa4ecfc1be3bc59d80682bf6d85b872f2c32740e5cff94614b067f09d9a987d0dcf4b0478d6286b9da1084af7ed6d1dc428047ac4580c216a363c1e10d9a11cb5068f42d5bbd63014ceb83009228a4d41796ceb1432298f5b273dbca63e2f5a0f7d3572b1374cdf85d28a71706aa553d1a2b31aabbcad271c6be6d7d9d3b4cc0693314e08c8789678c6f086984f4f9d3cc7a86c5020a35498944693b7c6d1929b40e4b962f71b051b2f732657d3e8544a789094c214482ae8c823612f17e450e3c463c52335ffa900f0ab7a8cc0abd9c38fc5d9c997b747==--==66d2a6c19fe7d7a40ff1417d
|
||||||
1
config/credentials/development.key
Normal file
1
config/credentials/development.key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
f7e00100b7db4e78aaf5c622271476dc31e47478c49c346d7d9e9ed2a9ee0289
|
||||||
1
config/credentials/production.enc
Symbolic link
1
config/credentials/production.enc
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
development.enc
|
||||||
1
config/credentials/production.key
Symbolic link
1
config/credentials/production.key
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
development.key
|
||||||
1
config/credentials/testEnvironment.enc
Normal file
1
config/credentials/testEnvironment.enc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
9f545b6e36205feadbbc6b788963c4efa3fb2179c9432ca4a14c40b4f2a06b2b0a086cbd987db98e55243f56158702e8974a7e1323c0694c2b572e77f468112dc675057d11b041f042cf310a2548c8f22b927f8b3da42e7ba326f15f4bd0a69b6541192acd09b79d47effbfad3c0639d067258684743e727342bbe78a036292b56436a50513b6a630b6e8321d6862bbd16e015e457ff8b28d1a44de2aca2f9cbc42ba0b19bfbe56106860222fc5cd58e0ab8e42d==--==cd1cef336de7155de2fbd0fc
|
||||||
1
config/credentials/testEnvironment.key
Normal file
1
config/credentials/testEnvironment.key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
58c2f85a0c5b7ee2a9d5b96773d06700dd0c40a0f6a09f5e33fa5c0ce85a4bf0
|
||||||
13
config/development.local.yml
Normal file
13
config/development.local.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
database:
|
||||||
|
username: "eugene"
|
||||||
|
ssl_mode: "disable"
|
||||||
|
app_secret: "0a7b29dd092dd09ac0ea30c5ba59baaf"
|
||||||
|
app_secret_block: "a55152f70362669c6ea18f17f593ff3b"
|
||||||
|
path_prefix: "."
|
||||||
|
paths:
|
||||||
|
credentials: "config/credentials"
|
||||||
|
migrations: "db/migrations"
|
||||||
|
public: "public"
|
||||||
|
views: "internal/views"
|
||||||
|
oidc:
|
||||||
|
redirect_url: "http://localhost:3000"
|
||||||
26
config/settings.yml
Normal file
26
config/settings.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
app_secret: "override_me"
|
||||||
|
app_secret_block: "override_me"
|
||||||
|
path_prefix: "/opt/app-root"
|
||||||
|
paths:
|
||||||
|
credentials: "config/credentials"
|
||||||
|
migrations: "migrations"
|
||||||
|
public: "public"
|
||||||
|
views: "views"
|
||||||
|
idms:
|
||||||
|
base_url: "https://identity.office.clintonambulance.com"
|
||||||
|
cars_group_id: "01175d60-1a2c-4255-8976-32e6cb205341"
|
||||||
|
match_threshold: 3
|
||||||
|
nocodb:
|
||||||
|
base_url: https://sheets.office.clintonambulance.com
|
||||||
|
employees_table_id: "m12l1ptaalz9ciq"
|
||||||
|
infractions_table_id: "myr46d3wdd4hhyg"
|
||||||
|
negative_infraction_id: 9
|
||||||
|
no_points_view_id: "vwjtzv5npqodcy71"
|
||||||
|
oidc:
|
||||||
|
issuer: "https://identity.office.clintonambulance.com/application/o/calculate-negative-points/"
|
||||||
|
redirect_url: "https://calculatenegativepoints.office.clintonambulance.com"
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- email
|
||||||
|
- offline_access
|
||||||
102
docs/openapi/api.yaml
Normal file
102
docs/openapi/api.yaml
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
description: Easily maintain Calculate Negative Points
|
||||||
|
title: Calculate Negative Points
|
||||||
|
version: v1.0.0
|
||||||
|
paths:
|
||||||
|
/api/process:
|
||||||
|
post:
|
||||||
|
description: Process Negative Points
|
||||||
|
operationId: requests/negative_points_processor.NegativePointsProcessor
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/FormDataNegativePointsProcessorNegativePointsProcessorInput'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NegativePointsProcessorNegativePointsProcessorOutput'
|
||||||
|
description: OK
|
||||||
|
summary: Negative Points Processor
|
||||||
|
tags:
|
||||||
|
- Negative Points Processor
|
||||||
|
/api/users/current:
|
||||||
|
delete:
|
||||||
|
description: Logout current user
|
||||||
|
operationId: requests/users.Logout
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
additionalProperties: {}
|
||||||
|
type:
|
||||||
|
- "null"
|
||||||
|
- object
|
||||||
|
description: OK
|
||||||
|
summary: Logout
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
get:
|
||||||
|
description: Retrieve the current user
|
||||||
|
operationId: requests/users.GetCurrentUser
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser'
|
||||||
|
description: OK
|
||||||
|
summary: Get Current User
|
||||||
|
tags:
|
||||||
|
- Users
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FormDataNegativePointsProcessorNegativePointsProcessorInput:
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
$ref: '#/components/schemas/MultipartFileHeader'
|
||||||
|
description: XLS schedule file to process
|
||||||
|
type: object
|
||||||
|
MultipartFileHeader:
|
||||||
|
contentMediaType: application/octet-stream
|
||||||
|
format: binary
|
||||||
|
type: string
|
||||||
|
NegativePointsProcessorNegativePointsProcessorOutput:
|
||||||
|
properties:
|
||||||
|
employees:
|
||||||
|
description: List of employees who had negative points
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- employees
|
||||||
|
type: object
|
||||||
|
TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser:
|
||||||
|
properties:
|
||||||
|
item:
|
||||||
|
$ref: '#/components/schemas/TypesUiUser'
|
||||||
|
required:
|
||||||
|
- item
|
||||||
|
type: object
|
||||||
|
TypesUiUser:
|
||||||
|
properties:
|
||||||
|
first_name:
|
||||||
|
type: string
|
||||||
|
groups:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
last_name:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
369
find_overlaps.go
369
find_overlaps.go
@@ -1,369 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/samber/lo"
|
|
||||||
)
|
|
||||||
|
|
||||||
const EugeneId = 23
|
|
||||||
const NegativeInfractionId = 9
|
|
||||||
const BaseUrl = "https://sheets.office.clintonambulance.com"
|
|
||||||
const EmployeesTableId = "m12l1ptaalz9ciq"
|
|
||||||
const NoPointsViewId = "vwjtzv5npqodcy71"
|
|
||||||
const InfractionsTableId = "myr46d3wdd4hhyg"
|
|
||||||
|
|
||||||
// NocoDBRecord represents a record from the NocoDB API
|
|
||||||
type NocoDBRecord struct {
|
|
||||||
ID int `json:"ID"`
|
|
||||||
Name string `json:"Name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NocoDBResponse represents the API response structure
|
|
||||||
type NocoDBResponse struct {
|
|
||||||
List []NocoDBRecord `json:"list"`
|
|
||||||
PageInfo struct {
|
|
||||||
TotalRows int `json:"totalRows"`
|
|
||||||
Page int `json:"page"`
|
|
||||||
PageSize int `json:"pageSize"`
|
|
||||||
IsFirstPage bool `json:"isFirstPage"`
|
|
||||||
IsLastPage bool `json:"isLastPage"`
|
|
||||||
} `json:"pageInfo"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NocoDBRequest struct {
|
|
||||||
EmployeeId int `json:"Employees_id"`
|
|
||||||
ReportedBy int `json:"Employees_id1"`
|
|
||||||
InfractionId int `json:"Infractions_id"`
|
|
||||||
Date string `json:"Date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func noPointsUrl() string {
|
|
||||||
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", EmployeesTableId, "records")
|
|
||||||
return fmt.Sprintf("%s?viewId=%s", combinedUrl, NoPointsViewId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInfractionsUrl() string {
|
|
||||||
combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", InfractionsTableId, "records")
|
|
||||||
return combinedUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstDayOfMonth() string {
|
|
||||||
// Load America/Detroit timezone
|
|
||||||
loc, err := time.LoadLocation("America/Detroit")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current time in Detroit timezone
|
|
||||||
now := time.Now().In(loc)
|
|
||||||
|
|
||||||
// Create midnight of the first day of the current month
|
|
||||||
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
|
||||||
|
|
||||||
// Return ISO8601 formatted string
|
|
||||||
return firstDay.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
// levenshteinDistance calculates the edit distance between two strings
|
|
||||||
func levenshteinDistance(s1, s2 string) int {
|
|
||||||
s1 = strings.ToLower(s1)
|
|
||||||
s2 = strings.ToLower(s2)
|
|
||||||
|
|
||||||
len1 := len(s1)
|
|
||||||
len2 := len(s2)
|
|
||||||
|
|
||||||
matrix := make([][]int, len1+1)
|
|
||||||
for i := range matrix {
|
|
||||||
matrix[i] = make([]int, len2+1)
|
|
||||||
matrix[i][0] = i
|
|
||||||
}
|
|
||||||
for j := range matrix[0] {
|
|
||||||
matrix[0][j] = j
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 1; i <= len1; i++ {
|
|
||||||
for j := 1; j <= len2; j++ {
|
|
||||||
cost := 0
|
|
||||||
if s1[i-1] != s2[j-1] {
|
|
||||||
cost = 1
|
|
||||||
}
|
|
||||||
matrix[i][j] = min(
|
|
||||||
matrix[i-1][j]+1,
|
|
||||||
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matrix[len1][len2]
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeName removes extra whitespace and punctuation, converts to lowercase
|
|
||||||
func normalizeName(name string) string {
|
|
||||||
// Remove extra whitespace
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
|
||||||
|
|
||||||
// Remove common punctuation but keep hyphens
|
|
||||||
name = strings.Map(func(r rune) rune {
|
|
||||||
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
|
|
||||||
return unicode.ToLower(r)
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}, name)
|
|
||||||
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// convertNameFormat converts "LastName, FirstName" to "FirstName LastName"
|
|
||||||
func convertNameFormat(name string) string {
|
|
||||||
parts := strings.Split(name, ",")
|
|
||||||
if len(parts) == 2 {
|
|
||||||
lastName := strings.TrimSpace(parts[0])
|
|
||||||
firstName := strings.TrimSpace(parts[1])
|
|
||||||
return firstName + " " + lastName
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// toTitleCase converts a name to Title Case
|
|
||||||
func toTitleCase(name string) string {
|
|
||||||
words := strings.Fields(name)
|
|
||||||
for i, word := range words {
|
|
||||||
if len(word) > 0 {
|
|
||||||
// Handle hyphenated names
|
|
||||||
if strings.Contains(word, "-") {
|
|
||||||
parts := strings.Split(word, "-")
|
|
||||||
for j, part := range parts {
|
|
||||||
if len(part) > 0 {
|
|
||||||
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
words[i] = strings.Join(parts, "-")
|
|
||||||
} else {
|
|
||||||
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(words, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseXLSFile extracts names from the HTML-formatted XLS file
|
|
||||||
func parseXLSFile(filename string) ([]string, error) {
|
|
||||||
content, err := os.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read XLS file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract names from HTML table cells with class="smallbold"
|
|
||||||
// Pattern matches: <td valign=top class="smallbold">NAME</td>
|
|
||||||
pattern := regexp.MustCompile(`<td[^>]*class="smallbold"[^>]*>([^<]+)</td>`)
|
|
||||||
matches := pattern.FindAllStringSubmatch(string(content), -1)
|
|
||||||
|
|
||||||
names := make([]string, 0)
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, match := range matches {
|
|
||||||
if len(match) > 1 {
|
|
||||||
name := strings.TrimSpace(match[1])
|
|
||||||
// Filter out non-name entries (like "Regular Shift", "Total:", etc.)
|
|
||||||
if name != "" && !strings.Contains(name, "Total") &&
|
|
||||||
!strings.Contains(name, "Shift") && !strings.Contains(name, ":") &&
|
|
||||||
strings.Contains(name, ",") { // Names should have comma
|
|
||||||
normalized := normalizeName(name)
|
|
||||||
if !seen[normalized] {
|
|
||||||
seen[normalized] = true
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchNamesFromAPI fetches names from the NocoDB API
|
|
||||||
func fetchFromAPI(apiURL, apiToken string) ([]NocoDBRecord, error) {
|
|
||||||
records := []NocoDBRecord{}
|
|
||||||
offset := 0
|
|
||||||
limit := 25
|
|
||||||
isLastPage := false
|
|
||||||
|
|
||||||
for !isLastPage {
|
|
||||||
// Build URL with pagination
|
|
||||||
url := fmt.Sprintf("%s&offset=%d&limit=%d", apiURL, offset, limit)
|
|
||||||
|
|
||||||
// Create HTTP request
|
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authorization header
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// Make the request
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Check response status
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and parse response
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var nocoDBResp NocoDBResponse
|
|
||||||
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract names from records
|
|
||||||
records = append(records, nocoDBResp.List...)
|
|
||||||
|
|
||||||
// Check if we've reached the last page
|
|
||||||
isLastPage = nocoDBResp.PageInfo.IsLastPage
|
|
||||||
|
|
||||||
// Update offset for next page
|
|
||||||
offset += limit
|
|
||||||
}
|
|
||||||
|
|
||||||
return records, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasMatch(record NocoDBRecord, candidates []string, threshold int) bool {
|
|
||||||
normalizedName := normalizeName(toTitleCase(record.Name))
|
|
||||||
bestDistance := threshold + 1
|
|
||||||
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
normalizedCandidate := normalizeName(candidate)
|
|
||||||
distance := levenshteinDistance(normalizedName, normalizedCandidate)
|
|
||||||
|
|
||||||
if distance < bestDistance {
|
|
||||||
bestDistance = distance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestDistance <= threshold {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Define command-line flags
|
|
||||||
xlsFile := flag.String("xls", "", "Path to the XLS file (required)")
|
|
||||||
apiToken := flag.String("api-token", "", "NocoDB API token (or set NOCODB_API_TOKEN environment variable)")
|
|
||||||
threshold := flag.Int("threshold", 3, "Maximum edit distance for fuzzy matching")
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
// Get API token from environment if not provided via flag
|
|
||||||
token := *apiToken
|
|
||||||
if token == "" {
|
|
||||||
token = os.Getenv("NOCODB_API_TOKEN")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required arguments
|
|
||||||
if *xlsFile == "" {
|
|
||||||
fmt.Println("Error: -xls flag is required")
|
|
||||||
fmt.Println("\nUsage:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if token == "" {
|
|
||||||
fmt.Println("Error: API token is required")
|
|
||||||
fmt.Println("Provide it via -api-token flag or NOCODB_API_TOKEN environment variable")
|
|
||||||
fmt.Println("\nUsage:")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
xlsNames, err := parseXLSFile(*xlsFile)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error parsing XLS file: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
records, err := fetchFromAPI(noPointsUrl(), token)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error fetching data from API: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert XLS names to same format as API names (FirstName LastName) and normalize casing
|
|
||||||
xlsNamesConverted := make([]string, len(xlsNames))
|
|
||||||
for i, name := range xlsNames {
|
|
||||||
converted := convertNameFormat(name)
|
|
||||||
xlsNamesConverted[i] = toTitleCase(converted)
|
|
||||||
}
|
|
||||||
|
|
||||||
overlaps := lo.Filter(records, func(r NocoDBRecord, _ int) bool {
|
|
||||||
return hasMatch(r, xlsNamesConverted, *threshold)
|
|
||||||
})
|
|
||||||
|
|
||||||
requestObjects := lo.Map(overlaps, func(r NocoDBRecord, _ int) NocoDBRequest {
|
|
||||||
return NocoDBRequest{
|
|
||||||
EmployeeId: r.ID,
|
|
||||||
ReportedBy: EugeneId,
|
|
||||||
InfractionId: NegativeInfractionId,
|
|
||||||
Date: firstDayOfMonth(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Marshal request objects to JSON
|
|
||||||
jsonData, err := json.Marshal(requestObjects)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error marshaling request objects: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create POST request
|
|
||||||
req, err := http.NewRequest("POST", addInfractionsUrl(), strings.NewReader(string(jsonData)))
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating POST request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add authorization header
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// Make the request
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error submitting request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Check response status
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Successfully submitted %d records\n", len(requestObjects))
|
|
||||||
}
|
|
||||||
1
frontend/.env
Symbolic link
1
frontend/.env
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../.env
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "calculate_negative_points",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bunx --bun vite",
|
||||||
|
"build": "bunx --bun vite build --emptyOutDir",
|
||||||
|
"lint": "bunx --bun eslint .",
|
||||||
|
"typecheck": "bunx --bun tsc --noEmit",
|
||||||
|
"generate-api-types": "bunx --bun openapi-typescript ../docs/openapi/api.yaml --output ./src/api/generated/schema.ts",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-form": "^1.28.0",
|
||||||
|
"@tanstack/react-query": "^5.79.2",
|
||||||
|
"@tanstack/react-router": "^1.120.12",
|
||||||
|
"@types/lodash": "^4.17.17",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"bootstrap": "^5.3.6",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
|
"framer-motion": "^12.16.0",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"oidc-client-ts": "^3.2.1",
|
||||||
|
"pluralize": "^8.0.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-bootstrap-date-picker": "^5.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-flatpickr": "^4.0.10",
|
||||||
|
"react-oidc-context": "^3.3.0",
|
||||||
|
"reactstrap": "^9.2.3",
|
||||||
|
"sass": "^1.89.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@rollup/plugin-image": "^3.0.3",
|
||||||
|
"@tanstack/react-router-devtools": "^1.120.12",
|
||||||
|
"@tanstack/router-plugin": "^1.120.12",
|
||||||
|
"@types/classnames": "^2.3.4",
|
||||||
|
"@types/flatpickr": "^3.1.4",
|
||||||
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"@types/pluralize": "^0.0.33",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-bootstrap-date-picker": "^4.0.12",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"openapi-typescript": "^7.8.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
150
frontend/src/api/generated/schema.ts
Normal file
150
frontend/src/api/generated/schema.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
"/api/process": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Negative Points Processor
|
||||||
|
* @description Process Negative Points
|
||||||
|
*/
|
||||||
|
post: operations["requests/negative_points_processor.NegativePointsProcessor"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/current": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get Current User
|
||||||
|
* @description Retrieve the current user
|
||||||
|
*/
|
||||||
|
get: operations["requests/users.GetCurrentUser"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/**
|
||||||
|
* Logout
|
||||||
|
* @description Logout current user
|
||||||
|
*/
|
||||||
|
delete: operations["requests/users.Logout"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
FormDataNegativePointsProcessorNegativePointsProcessorInput: {
|
||||||
|
/** @description XLS schedule file to process */
|
||||||
|
file?: components["schemas"]["MultipartFileHeader"];
|
||||||
|
};
|
||||||
|
/** Format: binary */
|
||||||
|
MultipartFileHeader: string;
|
||||||
|
NegativePointsProcessorNegativePointsProcessorOutput: {
|
||||||
|
/** @description List of employees who had negative points */
|
||||||
|
employees: string[];
|
||||||
|
};
|
||||||
|
TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser: {
|
||||||
|
item: components["schemas"]["TypesUiUser"];
|
||||||
|
};
|
||||||
|
TypesUiUser: {
|
||||||
|
first_name?: string;
|
||||||
|
groups?: string[];
|
||||||
|
id: string;
|
||||||
|
last_name?: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
"requests/negative_points_processor.NegativePointsProcessor": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["FormDataNegativePointsProcessorNegativePointsProcessorInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["NegativePointsProcessorNegativePointsProcessorOutput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"requests/users.GetCurrentUser": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
"requests/users.Logout": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
3
frontend/src/api/index.ts
Normal file
3
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./request";
|
||||||
|
export * from "./users";
|
||||||
|
export * from "./process";
|
||||||
13
frontend/src/api/process.ts
Normal file
13
frontend/src/api/process.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as schema from "./generated/schema";
|
||||||
|
import { request } from "@/api";
|
||||||
|
|
||||||
|
type ProcessPath = schema.paths["/api/process"];
|
||||||
|
type ProcessResponse =
|
||||||
|
ProcessPath["post"]["responses"]["200"]["content"]["application/json"];
|
||||||
|
|
||||||
|
export const processFile = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
return await request.postFormData<ProcessResponse>("/api/process", formData);
|
||||||
|
};
|
||||||
121
frontend/src/api/request.ts
Normal file
121
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import axios, { AxiosError, AxiosPromise } from "axios";
|
||||||
|
const jsonHeaders = (additional?: { [x: string]: string }) => ({
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...additional,
|
||||||
|
});
|
||||||
|
|
||||||
|
const withUnauthorizedRedirect = async <T>(axiosFn: () => AxiosPromise<T>) => {
|
||||||
|
try {
|
||||||
|
const res = await axiosFn();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const status = (e as AxiosError).response?.status;
|
||||||
|
|
||||||
|
if (status && [401, 403].includes(status)) {
|
||||||
|
window.location.href = `/auth/login?returnTo=${window.location.href}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetRequest = { url: string } & (
|
||||||
|
| {
|
||||||
|
pagination?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pagination: true;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const request = {
|
||||||
|
getUnauthenticated: async <T>(url: string): Promise<T> => {
|
||||||
|
const { data } = await axios.get<T>(url, {
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
get: async <T>(request: GetRequest): Promise<T> => {
|
||||||
|
const params =
|
||||||
|
request.pagination === true
|
||||||
|
? { page: request.page, page_size: request.page_size }
|
||||||
|
: undefined;
|
||||||
|
const { data } = await withUnauthorizedRedirect(() =>
|
||||||
|
axios.get<T>(request.url, {
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
params,
|
||||||
|
withCredentials: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
put: async <T>(url: string, body: T | null): Promise<T> => {
|
||||||
|
const { data } = await withUnauthorizedRedirect(() => {
|
||||||
|
return axios.put<T>(url, body, {
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
post: async <T>(url: string, body: T): Promise<T> => {
|
||||||
|
const { data } = await withUnauthorizedRedirect(() => {
|
||||||
|
return axios.post<T>(url, body, {
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
delete: async <T>(url: string): Promise<T> => {
|
||||||
|
console.log(url);
|
||||||
|
const { data } = await withUnauthorizedRedirect(() => {
|
||||||
|
return axios.delete<T>(url, {
|
||||||
|
headers: jsonHeaders(),
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
postFormData: async <T>(url: string, body: FormData): Promise<T> => {
|
||||||
|
const { data } = await withUnauthorizedRedirect(() => {
|
||||||
|
return axios.post<T>(url, body, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
download: async <T>(url: string, body: T) => {
|
||||||
|
const response = await withUnauthorizedRedirect(() => {
|
||||||
|
return axios.post<Blob>(url, body, {
|
||||||
|
responseType: "blob",
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log({ response });
|
||||||
|
|
||||||
|
const contentDisposition = response.headers["content-disposition"];
|
||||||
|
let filename = "";
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
|
||||||
|
if (match) filename = match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
const urlObj = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = urlObj;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(urlObj);
|
||||||
|
a.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
16
frontend/src/api/users.ts
Normal file
16
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import * as schema from "./generated/schema";
|
||||||
|
import { request } from "@/api";
|
||||||
|
type CurrentUserPath = schema.paths["/api/users/current"];
|
||||||
|
type CurrentUserResponse =
|
||||||
|
CurrentUserPath["get"]["responses"]["200"]["content"]["application/json"];
|
||||||
|
export type User = CurrentUserResponse["user"];
|
||||||
|
|
||||||
|
export const getCurrentUser = async () => {
|
||||||
|
return await request.getUnauthenticated<CurrentUserResponse>(
|
||||||
|
"/api/users/current"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logoutUser = async () => {
|
||||||
|
return await request.delete("/api/users/current");
|
||||||
|
};
|
||||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
65
frontend/src/components/Navbar.tsx
Normal file
65
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Navbar,
|
||||||
|
Nav,
|
||||||
|
NavbarBrand,
|
||||||
|
NavItem,
|
||||||
|
NavLink,
|
||||||
|
Button,
|
||||||
|
UncontrolledDropdown,
|
||||||
|
DropdownToggle,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownItem,
|
||||||
|
} from "reactstrap";
|
||||||
|
import { logoutUser } from "@/api";
|
||||||
|
import assetUrl from "@/assets/logo.png";
|
||||||
|
import { useRouter } from "@tanstack/react-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useUser } from "@/hooks";
|
||||||
|
|
||||||
|
export const NavbarComponent: React.FC = () => {
|
||||||
|
const imgUrl = new URL(assetUrl, import.meta.url).href;
|
||||||
|
const router = useRouter();
|
||||||
|
const onLogout = useCallback(() => {
|
||||||
|
logoutUser().then(() => router.invalidate());
|
||||||
|
}, [router]);
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Navbar color="light" expand="md">
|
||||||
|
<Nav navbar className="align-items-center">
|
||||||
|
<NavbarBrand href="/">
|
||||||
|
<img src={imgUrl} alt="CAASA Logo" width="48" />
|
||||||
|
</NavbarBrand>
|
||||||
|
<NavItem>
|
||||||
|
<NavLink href="/">Home</NavLink>
|
||||||
|
</NavItem>
|
||||||
|
</Nav>
|
||||||
|
<Nav>
|
||||||
|
{user ? (
|
||||||
|
<UncontrolledDropdown
|
||||||
|
inNavbar
|
||||||
|
nav
|
||||||
|
title={user.name}
|
||||||
|
id="basic-nav-dropdown"
|
||||||
|
>
|
||||||
|
<DropdownToggle nav caret>
|
||||||
|
{user.name}
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu right>
|
||||||
|
<DropdownItem onClick={onLogout}>Logout</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</UncontrolledDropdown>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
onClick={() =>
|
||||||
|
(window.location.href = `/auth/login?returnTo=${window.location.href}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Nav>
|
||||||
|
</Navbar>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
frontend/src/components/ProtectedWrapper.tsx
Normal file
20
frontend/src/components/ProtectedWrapper.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useRoles } from "@/hooks";
|
||||||
|
import { every } from "lodash";
|
||||||
|
|
||||||
|
type ProtectedWrapperProps = {
|
||||||
|
requiredRoles: string[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProtectedWrapper: React.FC<ProtectedWrapperProps> = ({
|
||||||
|
requiredRoles,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const roles = useRoles();
|
||||||
|
|
||||||
|
if (!every(requiredRoles, (r) => roles.includes(r))) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
54
frontend/src/components/SidePanel/SidePanel.tsx
Normal file
54
frontend/src/components/SidePanel/SidePanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { usePortal } from "@/hooks";
|
||||||
|
|
||||||
|
import { Button, Container } from "reactstrap";
|
||||||
|
|
||||||
|
export type SidePanelProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isVisible?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
style?: { [x: string]: string };
|
||||||
|
title: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SidePanel: React.FC<SidePanelProps> = ({
|
||||||
|
children,
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
style = {},
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const { portalRoot, createPortal } = usePortal();
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.aside
|
||||||
|
className="sidepanel d-flex flex-column"
|
||||||
|
role="complementary"
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: "0%" }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ duration: 0.4 }}
|
||||||
|
key="side-panel"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
<h4>{title}</h4>
|
||||||
|
</Container>
|
||||||
|
<hr className="w-100 flex-shrink-0" />
|
||||||
|
|
||||||
|
<Container className="d-flex flex-column flex-grow-1">
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Button className="text-start" color="link" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</motion.aside>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
portalRoot
|
||||||
|
);
|
||||||
|
};
|
||||||
1
frontend/src/components/SidePanel/index.ts
Normal file
1
frontend/src/components/SidePanel/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./SidePanel";
|
||||||
1
frontend/src/components/SidePanel/utils.ts
Normal file
1
frontend/src/components/SidePanel/utils.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const SIDEPANEL_ANIMATION_TIME = 400 as const;
|
||||||
3
frontend/src/components/index.ts
Normal file
3
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./Navbar";
|
||||||
|
export * from "./ProtectedWrapper";
|
||||||
|
export * from "./SidePanel";
|
||||||
2
frontend/src/hooks/index.ts
Normal file
2
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./useUser";
|
||||||
|
export * from "./usePortal";
|
||||||
24
frontend/src/hooks/usePortal.ts
Normal file
24
frontend/src/hooks/usePortal.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
export const usePortal = () => {
|
||||||
|
const ref = useRef<HTMLDivElement>(document.createElement("div"));
|
||||||
|
const node = ref.current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
node.id = "portal";
|
||||||
|
document.body.appendChild(node);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!node) return;
|
||||||
|
document.body.removeChild(node);
|
||||||
|
};
|
||||||
|
}, [node]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
portalRoot: node,
|
||||||
|
createPortal,
|
||||||
|
};
|
||||||
|
};
|
||||||
19
frontend/src/hooks/useUser.ts
Normal file
19
frontend/src/hooks/useUser.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Route } from "@/routes/__root";
|
||||||
|
|
||||||
|
export const useRoles = () => {
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
if (!user?.groups) return [];
|
||||||
|
|
||||||
|
return user.groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUser = () => {
|
||||||
|
const data = Route.useLoaderData();
|
||||||
|
|
||||||
|
if (!data) return undefined;
|
||||||
|
|
||||||
|
const { item } = data;
|
||||||
|
|
||||||
|
return item;
|
||||||
|
};
|
||||||
24
frontend/src/main.scss
Normal file
24
frontend/src/main.scss
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
aside.sidepanel {
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: -20px 0 25px -5px rgba(0, 0, 0, 0.1), -10px 0 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 24px 20px;
|
||||||
|
position: fixed;
|
||||||
|
overflow: auto;
|
||||||
|
right: 0;
|
||||||
|
top: 74px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
// Medium screens (768px+): 50% width
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
padding: 36px 30px;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large screens (992px+): 33% width
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
padding: 48px 40px;
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/main.tsx
Normal file
26
frontend/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import "./main.scss";
|
||||||
|
|
||||||
|
// Import the generated route tree
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
|
// Create a new router instance
|
||||||
|
const router = createRouter({ routeTree });
|
||||||
|
|
||||||
|
export const API_URL = "/api" as const;
|
||||||
|
|
||||||
|
// Register the router instance for type safety
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
59
frontend/src/routeTree.gen.ts
Normal file
59
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from "./routes/__root"
|
||||||
|
import { Route as IndexRouteImport } from "./routes/index"
|
||||||
|
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: "/",
|
||||||
|
path: "/",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
"/": typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
"/": typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
"/": typeof IndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: "/"
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: "/"
|
||||||
|
id: "__root__" | "/"
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
"/": {
|
||||||
|
id: "/"
|
||||||
|
path: "/"
|
||||||
|
fullPath: "/"
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
24
frontend/src/routes/__root.tsx
Normal file
24
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
import { NavbarComponent } from "@/components";
|
||||||
|
import { getCurrentUser } from "@/api";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavbarComponent />
|
||||||
|
<Outlet />
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loader: getCurrentUser,
|
||||||
|
errorComponent: () => (
|
||||||
|
<>
|
||||||
|
<NavbarComponent />
|
||||||
|
<div>Login to continue</div>
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
101
frontend/src/routes/index.tsx
Normal file
101
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { processFile } from "@/api/process";
|
||||||
|
|
||||||
|
const twoMonthsAgo = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(now.getFullYear(), now.getMonth() - 2, 1);
|
||||||
|
const end = new Date(target.getFullYear(), target.getMonth() + 1, 0);
|
||||||
|
const month = target.toLocaleString("en-US", { month: "long" });
|
||||||
|
return `${month} 1 - ${month} ${end.getDate()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Index = () => {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"idle" | "uploading" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [employees, setEmployees] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setStatus("uploading");
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await processFile(file);
|
||||||
|
setEmployees(result.employees);
|
||||||
|
setStatus("success");
|
||||||
|
setFile(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : "Failed to process file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h3>Process Negative Points</h3>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="file" className="form-label">
|
||||||
|
Upload XLS Schedule File
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
className="form-control"
|
||||||
|
accept=".xls,.xlsx"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFile(e.target.files?.[0] ?? null);
|
||||||
|
setStatus("idle");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
This file must come from ESO. To get it, go to{" "}
|
||||||
|
<strong>
|
||||||
|
ESO Scheduler > Employees > Employee Reports > Employee
|
||||||
|
Hours Worked By Date Span
|
||||||
|
</strong>
|
||||||
|
.<br /> Select the following date range:{" "}
|
||||||
|
<strong>{twoMonthsAgo()}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!file || status === "uploading"}
|
||||||
|
>
|
||||||
|
{status === "uploading" ? "Processing..." : "Upload & Process"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status === "success" && (
|
||||||
|
<div className="alert alert-success mt-3">
|
||||||
|
<strong>File processed successfully.</strong>
|
||||||
|
{employees.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="mb-1 mt-2">Employees processed ({employees.length}):</p>
|
||||||
|
<ul className="mb-0">
|
||||||
|
{employees.map((name) => (
|
||||||
|
<li key={name}>{name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="alert alert-danger mt-3">{errorMessage}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: Index,
|
||||||
|
});
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Imports */
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@/test/*": ["./test/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["./src", "./test"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
34
frontend/vite.config.ts
Normal file
34
frontend/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
|
import image from "@rollup/plugin-image";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
TanStackRouterVite({
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
quoteStyle: "double",
|
||||||
|
target: "react",
|
||||||
|
}),
|
||||||
|
image(),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@/test": path.resolve(__dirname, "./test"),
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
manifest: true,
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
reactApp: "./src/main.tsx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outDir: "../public",
|
||||||
|
},
|
||||||
|
});
|
||||||
71
go.mod
71
go.mod
@@ -1,7 +1,72 @@
|
|||||||
module clintonambulance.com/calculate_negative_points
|
module clintonambulance.com/calculate_negative_points
|
||||||
|
|
||||||
go 1.25.5
|
go 1.24.0
|
||||||
|
|
||||||
require github.com/samber/lo v1.52.0
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
|
github.com/dsa0x/sicher v0.2.4
|
||||||
|
github.com/go-chi/chi/v5 v5.2.4
|
||||||
|
github.com/go-chi/cors v1.2.2
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||||
|
github.com/goforj/godump v1.9.0
|
||||||
|
github.com/gorilla/sessions v1.4.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/knadh/koanf/parsers/yaml v1.1.0
|
||||||
|
github.com/knadh/koanf/providers/confmap v1.0.0
|
||||||
|
github.com/knadh/koanf/providers/env v1.1.0
|
||||||
|
github.com/knadh/koanf/providers/file v1.2.1
|
||||||
|
github.com/knadh/koanf/providers/posflag v1.0.1
|
||||||
|
github.com/knadh/koanf/providers/structs v1.0.0
|
||||||
|
github.com/knadh/koanf/v2 v2.3.2
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.1
|
||||||
|
github.com/onsi/gomega v1.39.1
|
||||||
|
github.com/samber/lo v1.39.0
|
||||||
|
github.com/spf13/pflag v1.0.6
|
||||||
|
github.com/swaggest/openapi-go v0.2.59
|
||||||
|
github.com/swaggest/rest v0.2.75
|
||||||
|
github.com/swaggest/usecase v1.3.1
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
|
golang.org/x/oauth2 v0.34.0
|
||||||
|
golang.org/x/text v0.33.0
|
||||||
|
resty.dev/v3 v3.0.0-beta.6
|
||||||
|
)
|
||||||
|
|
||||||
require golang.org/x/text v0.22.0 // indirect
|
require (
|
||||||
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/fatih/structs v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b // indirect
|
||||||
|
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect
|
||||||
|
github.com/swaggest/form/v5 v5.1.1 // indirect
|
||||||
|
github.com/swaggest/jsonschema-go v0.3.78 // indirect
|
||||||
|
github.com/swaggest/refl v1.4.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect
|
||||||
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.7 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
192
go.sum
192
go.sum
@@ -1,4 +1,188 @@
|
|||||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU=
|
||||||
|
github.com/bool64/dev v0.2.40/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
|
||||||
|
github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E=
|
||||||
|
github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dsa0x/sicher v0.2.4 h1:XIOsyylc3lQ5Wf7PPIq5R80RggX7b7d0KNqHXvHqOhc=
|
||||||
|
github.com/dsa0x/sicher v0.2.4/go.mod h1:3+m3tC4maosJU24v/yJhXYqQxMbScCYIccryM9P9dGA=
|
||||||
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||||
|
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||||
|
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||||
|
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||||
|
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||||
|
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/goforj/godump v1.9.0 h1:Y/APfWKQKnJetXgVJxDqD7vEpTGSgAwbKJGmj0UAteI=
|
||||||
|
github.com/goforj/godump v1.9.0/go.mod h1:/Vy+p50JtOkwsFN5dA1HQ7LS5gtPk3f61DaP4UR2o4s=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||||
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
|
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
|
||||||
|
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||||
|
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||||
|
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8=
|
||||||
|
github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks=
|
||||||
|
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||||
|
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||||
|
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
|
||||||
|
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
|
||||||
|
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
|
||||||
|
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
|
||||||
|
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
|
||||||
|
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
|
||||||
|
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
|
||||||
|
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||||
|
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
|
||||||
|
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
|
||||||
|
github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4=
|
||||||
|
github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w=
|
||||||
|
github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=
|
||||||
|
github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||||
|
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||||
|
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||||
|
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
|
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
|
||||||
|
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ=
|
||||||
|
github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU=
|
||||||
|
github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY=
|
||||||
|
github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg=
|
||||||
|
github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw=
|
||||||
|
github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g=
|
||||||
|
github.com/swaggest/openapi-go v0.2.59 h1:9cUlCrSxbWn/Qn78IxitrhB5kaev0hOROfTxwywYLC0=
|
||||||
|
github.com/swaggest/openapi-go v0.2.59/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk=
|
||||||
|
github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k=
|
||||||
|
github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA=
|
||||||
|
github.com/swaggest/rest v0.2.75 h1:MW9zZ3d0kduJ2KdWnSYZIIrZJ1v3Kg+S7QZrDCZcXws=
|
||||||
|
github.com/swaggest/rest v0.2.75/go.mod h1:yw+PNgpNSdD6W46r60keVXdsBB+7SKt64i2qpeuBsq4=
|
||||||
|
github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w=
|
||||||
|
github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
|
||||||
|
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||||
|
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
|
||||||
|
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
|
||||||
|
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
|
||||||
|
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
|
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
|
||||||
|
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
|
||||||
|
|||||||
15
internal/api/middleware/add_request_id.go
Normal file
15
internal/api/middleware/add_request_id.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddRequestIDHeaderMiddleware(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set(middleware.RequestIDHeader, middleware.GetReqID(r.Context()))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
118
internal/api/middleware/current_user.go
Normal file
118
internal/api/middleware/current_user.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTokenAndGetClaims(config *config.ApplicationConfig, ctx context.Context, token string) (*oidc.IDToken, Claims, error) {
|
||||||
|
idToken, err := config.OidcConfig.Verifier.Verify(ctx, token)
|
||||||
|
claims := Claims{Groups: []string{}}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return idToken, claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = idToken.Claims(&claims)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return idToken, claims, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return idToken, claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveWithTokenAndClaims(next http.Handler, r *http.Request, w http.ResponseWriter, claims Claims, token *oidc.IDToken) {
|
||||||
|
ctx := context.WithValue(r.Context(), "user", token)
|
||||||
|
ctx = context.WithValue(ctx, "claims", claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentUserMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||||
|
middleware := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||||
|
rawIDToken, ok := session.Values["id_token"].(string)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In test environment, bypass OIDC verification and use mock claims
|
||||||
|
if config.Environment == "test" || config.Environment == "testEnvironment" {
|
||||||
|
mockClaims := Claims{
|
||||||
|
Sub: "test-user-id",
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Groups: []string{"calculate-negative-points-users"},
|
||||||
|
}
|
||||||
|
serveWithTokenAndClaims(next, r, w, mockClaims, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, claims, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
serveWithTokenAndClaims(next, r, w, claims, idToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, ok := session.Values["refresh_token"].(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt refresh
|
||||||
|
tokenSrc := config.OidcConfig.OAuth2.TokenSource(r.Context(), &oauth2.Token{
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
newToken, err := tokenSrc.Token()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to refresh token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save new tokens
|
||||||
|
newRawIDToken, ok := newToken.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "missing id_token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, claims, err = verifyTokenAndGetClaims(config, r.Context(), newRawIDToken)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid refreshed token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Values["id_token"] = newRawIDToken
|
||||||
|
if newToken.RefreshToken != "" {
|
||||||
|
session.Values["refresh_token"] = newToken.RefreshToken
|
||||||
|
}
|
||||||
|
if scope, ok := newToken.Extra("scope").(string); ok {
|
||||||
|
session.Values["scope"] = scope
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
|
||||||
|
serveWithTokenAndClaims(next, r, w, claims, idToken)
|
||||||
|
return
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return middleware, nil
|
||||||
|
}
|
||||||
64
internal/api/middleware/error.go
Normal file
64
internal/api/middleware/error.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
// "github.com/getsentry/sentry-go"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||||
|
"github.com/swaggest/rest"
|
||||||
|
"github.com/swaggest/rest/nethttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ErrorResponder(w http.ResponseWriter, error string, code int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(utils.NewApplicationError(error, code))
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorWithFields interface {
|
||||||
|
error
|
||||||
|
Fields() map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorHandler middleware centralizes error handling and translation of errors between different error types
|
||||||
|
func ErrorHandler(next http.Handler) http.Handler {
|
||||||
|
var h *nethttp.Handler
|
||||||
|
if nethttp.HandlerAs(next, &h) {
|
||||||
|
resp := func(ctx context.Context, err error) (int, interface{}) {
|
||||||
|
// Handle AWS errors
|
||||||
|
|
||||||
|
// Application-specific error handling: if this error has a marker interface, serialize it as JSON
|
||||||
|
var customErr utils.CustomApplicationError
|
||||||
|
if errors.As(err, &customErr) {
|
||||||
|
return customErr.HTTPStatus(), customErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// More detailed validation error messages
|
||||||
|
var validationErr errorWithFields
|
||||||
|
if errors.As(err, &validationErr) {
|
||||||
|
detailedErrorMessages := make([]string, len(validationErr.Fields()))
|
||||||
|
i := 0
|
||||||
|
for k, v := range validationErr.Fields() {
|
||||||
|
detailedErrorMessages[i] = fmt.Sprintf("%s: %v", k, v)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return http.StatusUnprocessableEntity, utils.ApplicationError{
|
||||||
|
Message: fmt.Sprintf("Validation errors: %s", strings.Join(detailedErrorMessages, ", ")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code, er := rest.Err(err)
|
||||||
|
return code, utils.ApplicationError{
|
||||||
|
Message: er.ErrorText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.MakeErrResp = resp
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
77
internal/api/middleware/jwt_middleware.go
Normal file
77
internal/api/middleware/jwt_middleware.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const userContextKey = contextKey("user")
|
||||||
|
|
||||||
|
func audMatch(aud interface{}, expected string) bool {
|
||||||
|
switch v := aud.(type) {
|
||||||
|
case string:
|
||||||
|
return v == expected
|
||||||
|
case []interface{}:
|
||||||
|
for _, val := range v {
|
||||||
|
if s, ok := val.(string); ok && s == expected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func JWTMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||||
|
middleware := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodDelete {
|
||||||
|
// Skip auth for safe methods like GET/HEAD
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
// Check if the header exists and starts with "Bearer "
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "Unauthorized: Bearer token missing or invalid", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the token by removing the "Bearer " prefix
|
||||||
|
//tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
//token, err := jwt.Parse(tokenStr, config.Jwt.KeySet.Keyfunc)
|
||||||
|
//if err != nil || !token.Valid {
|
||||||
|
// http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
|
||||||
|
//claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
//if !ok {
|
||||||
|
// http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
|
||||||
|
// Check `iss` and `aud`
|
||||||
|
// if claims["iss"] != config.Jwt.Issuer {
|
||||||
|
// http.Error(w, "Invalid issuer", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// audClaim := claims["aud"]
|
||||||
|
// if !audMatch(audClaim, config.Jwt.Audience) {
|
||||||
|
// http.Error(w, "Invalid audience", http.StatusUnauthorized)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
//ctx := context.WithValue(r.Context(), userContextKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(r.Context()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return middleware, nil
|
||||||
|
}
|
||||||
50
internal/api/middleware/logging.go
Normal file
50
internal/api/middleware/logging.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoggerContext = ContextKey("logger")
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoggingMiddleware(logger *zap.Logger) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
if ww.Status() < 300 && strings.HasPrefix(r.URL.Path, "/health") {
|
||||||
|
// Don't log health checks unless they fail (ping endpoint returns empty response, HTTP status 204).
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
fields := []zapcore.Field{
|
||||||
|
zap.Any("body", r.Body),
|
||||||
|
zap.String("remote_ip", r.RemoteAddr),
|
||||||
|
zap.String("method", r.Method),
|
||||||
|
zap.String("uri", r.URL.Path),
|
||||||
|
zap.String("request_id", middleware.GetReqID(r.Context())),
|
||||||
|
zap.Int("status", ww.Status()),
|
||||||
|
zap.Float64("latency_ms", float64(time.Since(start))/float64(time.Millisecond)),
|
||||||
|
zap.Int("size", ww.BytesWritten()),
|
||||||
|
}
|
||||||
|
logger.Info("HTTP request processed", fields...)
|
||||||
|
}()
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), LoggerContext, logger))
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/api/middleware/logout.go
Normal file
28
internal/api/middleware/logout.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Logout(config *config.ApplicationConfig) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
|
||||||
|
err := session.Save(r, w)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to delete session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
internal/api/middleware/oidc.go
Normal file
36
internal/api/middleware/oidc.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OidcMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) {
|
||||||
|
middleware := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := config.CookieStore.Get(r, config.SessionName)
|
||||||
|
rawIDToken, ok := session.Values["id_token"].(string)
|
||||||
|
if !ok {
|
||||||
|
// Not authenticated; redirect to login
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, _, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
session.Save(r, w)
|
||||||
|
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add token to context
|
||||||
|
ctx := context.WithValue(r.Context(), "id_token", idToken)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return middleware, nil
|
||||||
|
}
|
||||||
48
internal/api/middleware/pagination.go
Normal file
48
internal/api/middleware/pagination.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Pagination struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaginationContextKey = "pagination"
|
||||||
|
|
||||||
|
func PaginationMiddleware() func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page := 1
|
||||||
|
pageSize := 20
|
||||||
|
|
||||||
|
if p := r.URL.Query().Get("page"); p != "" {
|
||||||
|
if parsed, err := strconv.Atoi(p); err == nil && parsed >= 1 {
|
||||||
|
page = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ps := r.URL.Query().Get("page_size"); ps != "" {
|
||||||
|
if parsed, err := strconv.Atoi(ps); err == nil && parsed >= 1 {
|
||||||
|
pageSize = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), PaginationContextKey, Pagination{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
internal/api/requests/module.go
Normal file
36
internal/api/requests/module.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package views_api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/api/requests/negative_points_processor"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/api/requests/users"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/swaggest/rest/nethttp"
|
||||||
|
"github.com/swaggest/rest/web"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MountInternalApiEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) {
|
||||||
|
e.Route("/api", func(r chi.Router) {
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"http://localhost:3000"},
|
||||||
|
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"*"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
currentUserMiddlware, err := middleware.CurrentUserMiddleware(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
r.Use(currentUserMiddlware)
|
||||||
|
|
||||||
|
r.Method(http.MethodGet, "/users/current", nethttp.NewHandler(users.GetCurrentUser()))
|
||||||
|
r.Method(http.MethodPost, "/process", nethttp.NewHandler(negative_points_processor.NegativePointsProcessor(config, logger)))
|
||||||
|
r.With(middleware.Logout(config)).Method(http.MethodDelete, "/users/current", nethttp.NewHandler(users.Logout()))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
31
internal/api/requests/users/current.go
Normal file
31
internal/api/requests/users/current.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"go/types"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||||
|
internal_types "clintonambulance.com/calculate_negative_points/internal/types"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"github.com/swaggest/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetCurrentUser() usecase.Interactor {
|
||||||
|
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *internal_types.UiUserResponse) error {
|
||||||
|
ctxId := ctx.Value("user").(*oidc.IDToken)
|
||||||
|
ctxUser := ctx.Value("claims").(middleware.Claims)
|
||||||
|
|
||||||
|
if lo.IsNotEmpty(ctxId.Issuer) {
|
||||||
|
output.Item = internal_types.UiUser{
|
||||||
|
Name: ctxUser.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
u.SetDescription("Retrieve the current user")
|
||||||
|
u.SetTags("Users")
|
||||||
|
return u
|
||||||
|
}
|
||||||
17
internal/api/requests/users/logout.go
Normal file
17
internal/api/requests/users/logout.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package users
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"go/types"
|
||||||
|
|
||||||
|
"github.com/swaggest/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Logout() usecase.Interactor {
|
||||||
|
u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *map[string]interface{}) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
u.SetDescription("Logout current user")
|
||||||
|
u.SetTags("Users")
|
||||||
|
return u
|
||||||
|
}
|
||||||
335
internal/config/config.go
Normal file
335
internal/config/config.go
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/dsa0x/sicher"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/knadh/koanf/parsers/yaml"
|
||||||
|
"github.com/knadh/koanf/providers/confmap"
|
||||||
|
"github.com/knadh/koanf/providers/env"
|
||||||
|
"github.com/knadh/koanf/providers/file"
|
||||||
|
"github.com/knadh/koanf/providers/posflag"
|
||||||
|
"github.com/knadh/koanf/providers/structs"
|
||||||
|
"github.com/knadh/koanf/v2"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultEnvironment = "development"
|
||||||
|
|
||||||
|
type SicherObject interface {
|
||||||
|
SetEnvStyle(string)
|
||||||
|
LoadEnv(string, interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SicherConfig struct {
|
||||||
|
AppSecret string `env:"APP_SECRET" koanf:"app_secret"`
|
||||||
|
AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"`
|
||||||
|
NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"`
|
||||||
|
OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"`
|
||||||
|
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"`
|
||||||
|
ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"`
|
||||||
|
ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var NewSicherObject = func(environment string, path string) SicherObject {
|
||||||
|
return sicher.New(environment, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcConfig struct {
|
||||||
|
ClientId string `koanf:"oidc.client_id" validate:"required"`
|
||||||
|
ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"`
|
||||||
|
Issuer string `koanf:"oidc.issuer" validate:"required"`
|
||||||
|
Scopes []string `koanf:"oidc.scopes" validate:"required"`
|
||||||
|
Provider *oidc.Provider
|
||||||
|
Verifier *oidc.IDTokenVerifier
|
||||||
|
OAuth2 *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdmsConfig struct {
|
||||||
|
BaseUrl string `koanf:"idms.base_url" validate:"required"`
|
||||||
|
CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"`
|
||||||
|
Id Secret[string] `koanf:"idms.service_account_id" validate:"required"`
|
||||||
|
Password Secret[string] `koanf:"idms.service_account_password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NocoDBConfig struct {
|
||||||
|
ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"`
|
||||||
|
BaseUrl string `koanf:"nocodb.base_url" validate:"required"`
|
||||||
|
EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"`
|
||||||
|
InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"`
|
||||||
|
NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"`
|
||||||
|
NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplicationConfig struct {
|
||||||
|
AppSecret Secret[string] `koanf:"app_secret"`
|
||||||
|
AppSecretBlock Secret[string] `koanf:"app_secret_block"`
|
||||||
|
CookieStore *sessions.CookieStore
|
||||||
|
Environment string `koanf:"environment" validate:"required"`
|
||||||
|
Listen string `koanf:"listen" validate:"required,hostname_port"`
|
||||||
|
MatchThreshold int `koanf:"match_threshold" validate:"required"`
|
||||||
|
NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"`
|
||||||
|
OidcConfig OidcConfig `koanf:"oidc" validate:"required"`
|
||||||
|
PublicPath string `koanf:"paths.public"`
|
||||||
|
Idms IdmsConfig `koanf:"idms" validate:"required"`
|
||||||
|
SessionName string
|
||||||
|
ViewPath string `koanf:"paths.views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedCliArgs struct {
|
||||||
|
ConfigFiles *[]string
|
||||||
|
DatabasePasswordPath *string
|
||||||
|
Environment *string
|
||||||
|
Listen *string
|
||||||
|
Version *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) {
|
||||||
|
f := pflag.NewFlagSet("config", pflag.ContinueOnError)
|
||||||
|
f.Usage = func() {
|
||||||
|
fmt.Println(version.AppNameAndVersion(false))
|
||||||
|
fmt.Println(f.FlagUsages())
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedCliArgs := &ParsedCliArgs{
|
||||||
|
ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"),
|
||||||
|
DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"),
|
||||||
|
Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"),
|
||||||
|
Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"),
|
||||||
|
Version: f.Bool("version", false, "Show version"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := f.Parse(args[1:])
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
os.Stderr,
|
||||||
|
"%s\n\nERROR: %s\n\n%s",
|
||||||
|
version.AppNameAndVersion(false), err, f.FlagUsages(),
|
||||||
|
)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedCliArgs, f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load application configuration from config files, command line flags, and environment variables.
|
||||||
|
// An error will be returned only if _unexpected_ error happened, such as a file not being found.
|
||||||
|
// If the user specified a --help or a --version flag, the application will exit with a 0 status code.
|
||||||
|
// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message.
|
||||||
|
func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) {
|
||||||
|
k := koanf.New(".")
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
err := k.Load(confmap.Provider(config, "."), nil)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("error initializing config", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedCliArgs, f := ParseArgs(version, exit, args)
|
||||||
|
|
||||||
|
// Load the config files provided in the command line.
|
||||||
|
for _, c := range *parsedCliArgs.ConfigFiles {
|
||||||
|
err := k.Load(file.Provider(c), yaml.Parser())
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading configuration file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("No .env file found")
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath := func(p string) string {
|
||||||
|
combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p)
|
||||||
|
return combinedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override with environment variables
|
||||||
|
lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) {
|
||||||
|
if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil {
|
||||||
|
logger.Fatal("error parsing environment variables", zap.Error(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Override with command-line values
|
||||||
|
if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing command-line parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := k.Unmarshal("", &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *parsedCliArgs.Version {
|
||||||
|
fmt.Println(version.AppNameAndVersion(true))
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sicherConfig SicherConfig
|
||||||
|
|
||||||
|
s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials")))
|
||||||
|
s.SetEnvStyle("yaml") // default is dotenv
|
||||||
|
err = s.LoadEnv("", &sicherConfig)
|
||||||
|
|
||||||
|
cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret")))
|
||||||
|
cookieStore.Options.HttpOnly = true
|
||||||
|
cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS
|
||||||
|
cookieStore.Options.SameSite = http.SameSiteLaxMode
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing sicher parameters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var appConfig ApplicationConfig
|
||||||
|
|
||||||
|
unmarshalConf := koanf.UnmarshalConf{
|
||||||
|
DecoderConfig: &mapstructure.DecoderConfig{
|
||||||
|
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||||
|
SecretFilePathUnmarshalHookFunc(),
|
||||||
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
|
),
|
||||||
|
Metadata: nil,
|
||||||
|
Result: &appConfig,
|
||||||
|
WeaklyTypedInput: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
appConfig.AppSecret = SecretFromValue(k.String("app_secret"))
|
||||||
|
appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block"))
|
||||||
|
|
||||||
|
nocodbConfig := NocoDBConfig{
|
||||||
|
ApiToken: SecretFromValue(k.String("nocodb.api_token")),
|
||||||
|
BaseUrl: k.String("nocodb.base_url"),
|
||||||
|
EmployeesTableId: k.String("nocodb.employees_table_id"),
|
||||||
|
InfractionsTableId: k.String("nocodb.infractions_table_id"),
|
||||||
|
NegativeInfractionId: k.Int("nocodb.negative_infraction_id"),
|
||||||
|
NoPointsViewId: k.String("nocodb.no_points_view_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcConfig := OidcConfig{
|
||||||
|
ClientId: k.String("oidc.client_id"),
|
||||||
|
ClientSecret: SecretFromValue(k.String("oidc.client_secret")),
|
||||||
|
Issuer: k.String("oidc.issuer"),
|
||||||
|
Scopes: k.Strings("oidc.scopes"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if *parsedCliArgs.Environment != "testEnvironment" {
|
||||||
|
authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
oidcConfig.Provider = authProvider
|
||||||
|
oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")})
|
||||||
|
redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url"))
|
||||||
|
oidcConfig.OAuth2 = &oauth2.Config{
|
||||||
|
ClientID: k.String("oidc.client_id"),
|
||||||
|
ClientSecret: k.String("oidc.client_secret"),
|
||||||
|
Endpoint: authProvider.Endpoint(),
|
||||||
|
RedirectURL: redirectUrl,
|
||||||
|
Scopes: k.Strings("oidc.scopes"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appConfig.CookieStore = cookieStore
|
||||||
|
appConfig.Environment = *parsedCliArgs.Environment
|
||||||
|
appConfig.Idms = IdmsConfig{
|
||||||
|
BaseUrl: k.String("idms.base_url"),
|
||||||
|
CarsGroupId: k.String("idms.cars_group_id"),
|
||||||
|
Id: SecretFromValue(k.String("idms.service_account_id")),
|
||||||
|
Password: SecretFromValue(k.String("idms.service_account_password")),
|
||||||
|
}
|
||||||
|
appConfig.Listen = *parsedCliArgs.Listen
|
||||||
|
appConfig.MatchThreshold = k.Int("match_threshold")
|
||||||
|
appConfig.NocoDBConfig = nocodbConfig
|
||||||
|
appConfig.OidcConfig = oidcConfig
|
||||||
|
appConfig.PublicPath = configPath(k.String("paths.public"))
|
||||||
|
appConfig.SessionName = "calculate-negative-points"
|
||||||
|
appConfig.ViewPath = configPath(k.String("paths.views"))
|
||||||
|
|
||||||
|
validate := validator.New()
|
||||||
|
validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{})
|
||||||
|
err = validate.Struct(appConfig)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
fmt.Print("Could not create config")
|
||||||
|
exit(1)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &appConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPrefix = "APP_"
|
||||||
|
const pgPrefix = "PG_"
|
||||||
|
const databasePrefix = "DATABASE_"
|
||||||
|
const idmsPrefix = "IDMS_"
|
||||||
|
const oidcPrefix = "OIDC_"
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix
|
||||||
|
*/
|
||||||
|
func mapEnvVarNames(prefix string) func(s string) string {
|
||||||
|
return func(s string) string {
|
||||||
|
if prefix == databasePrefix {
|
||||||
|
return strings.ToLower(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
replacedPg := strings.Replace(s, "PG", "DATABASE", -1)
|
||||||
|
|
||||||
|
lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix))
|
||||||
|
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConfig() map[string]interface{} {
|
||||||
|
dbConfig := map[string]interface{}{
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5432,
|
||||||
|
"user": "postgres",
|
||||||
|
"name": "calculate-negative-points",
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"database": dbConfig,
|
||||||
|
"environment": defaultEnvironment,
|
||||||
|
"listen": "127.0.0.1:3000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} {
|
||||||
|
if secret, ok := field.Interface().(Secret[T]); ok {
|
||||||
|
return secret.Value()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
13
internal/config/config_suite_test.go
Normal file
13
internal/config/config_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfig(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Config Suite")
|
||||||
|
}
|
||||||
41
internal/config/config_test.go
Normal file
41
internal/config/config_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FakeSicherObject struct{}
|
||||||
|
|
||||||
|
func (_ FakeSicherObject) SetEnvStyle(_ string) {}
|
||||||
|
func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("ConfigTest", func() {
|
||||||
|
var version config.Version
|
||||||
|
var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
|
||||||
|
shouldNotExit := func(code int) {
|
||||||
|
// "Fatal" to mimic os.Exit
|
||||||
|
log.Default().Print("exit called with code", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
config.NewSicherObject = func(_ string, _ string) config.SicherObject {
|
||||||
|
return FakeSicherObject{}
|
||||||
|
}
|
||||||
|
version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("Errors if the config file is not found", func() {
|
||||||
|
argv := []string{"cmd", "-c", "config/no-such-file.yml"}
|
||||||
|
_, err := config.Load(version, shouldNotExit, argv, logger)
|
||||||
|
Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory"))
|
||||||
|
})
|
||||||
|
})
|
||||||
34
internal/config/logging.go
Normal file
34
internal/config/logging.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) {
|
||||||
|
parsedCliArgs, _ := ParseArgs(version, os.Exit, args)
|
||||||
|
syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)}
|
||||||
|
encoderConfig := zap.NewProductionEncoderConfig()
|
||||||
|
encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder
|
||||||
|
|
||||||
|
core := zapcore.NewCore(
|
||||||
|
zapcore.NewJSONEncoder(encoderConfig),
|
||||||
|
zap.CombineWriteSyncers(syncs...),
|
||||||
|
zap.DebugLevel,
|
||||||
|
)
|
||||||
|
logger := zap.New(core).WithOptions(
|
||||||
|
zap.AddStacktrace(zap.ErrorLevel),
|
||||||
|
zap.WithCaller(false),
|
||||||
|
).With(
|
||||||
|
zap.String("version", version.Release),
|
||||||
|
zap.String("environment", *parsedCliArgs.Environment),
|
||||||
|
)
|
||||||
|
finalizer := func() {
|
||||||
|
// This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880)
|
||||||
|
_ = logger.Sync()
|
||||||
|
}
|
||||||
|
return logger, finalizer
|
||||||
|
}
|
||||||
85
internal/config/secret.go
Normal file
85
internal/config/secret.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/mitchellh/mapstructure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecretValue defines what types of secrets this secret container supports
|
||||||
|
type SecretValue interface {
|
||||||
|
~string | ~[]string | ~map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretFilePath is the source to the file containing the secret.
|
||||||
|
type SecretFilePath string
|
||||||
|
|
||||||
|
const SecretPlaceholder = "[SECRET]"
|
||||||
|
|
||||||
|
// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable
|
||||||
|
type Secret[T SecretValue] struct {
|
||||||
|
value T
|
||||||
|
source SecretFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc.
|
||||||
|
func (s Secret[T]) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc.
|
||||||
|
func (s *Secret[T]) MarshalText() ([]byte, error) {
|
||||||
|
return []byte(SecretPlaceholder), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values
|
||||||
|
func (s *Secret[T]) UnmarshalText(text []byte) error {
|
||||||
|
var val T
|
||||||
|
switch any(val).(type) {
|
||||||
|
case string:
|
||||||
|
s.value = any(string(text)).(T)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return json.Unmarshal(text, &s.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Secret[T]) Value() T {
|
||||||
|
return s.value
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) {
|
||||||
|
contentBytes, err := os.ReadFile(string(path))
|
||||||
|
if err != nil {
|
||||||
|
return Secret[T]{}, err
|
||||||
|
}
|
||||||
|
secret := Secret[T]{source: path}
|
||||||
|
err = secret.UnmarshalText(bytes.TrimSpace(contentBytes))
|
||||||
|
return secret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecretFromValue[T SecretValue](value T) Secret[T] {
|
||||||
|
return Secret[T]{value: value}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret
|
||||||
|
func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType {
|
||||||
|
return func(from, to reflect.Type, data interface{}) (interface{}, error) {
|
||||||
|
if from != reflect.TypeOf(SecretFilePath("")) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
// Reflection does not work with generics as of 1.20, so we have to do this manually
|
||||||
|
if to == reflect.TypeOf(Secret[map[string]string]{}) {
|
||||||
|
return SecretFromSecretPath[map[string]string](data.(SecretFilePath))
|
||||||
|
} else if to == reflect.TypeOf(Secret[[]string]{}) {
|
||||||
|
return SecretFromSecretPath[[]string](data.(SecretFilePath))
|
||||||
|
} else if to == reflect.TypeOf(Secret[string]{}) {
|
||||||
|
return SecretFromSecretPath[string](data.(SecretFilePath))
|
||||||
|
} else {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/config/testdata/DATABASE_SECRET.sample
vendored
Normal file
1
internal/config/testdata/DATABASE_SECRET.sample
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fakesecret
|
||||||
22
internal/config/testdata/settings.test.yml
vendored
Normal file
22
internal/config/testdata/settings.test.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
app_secret: "app secret"
|
||||||
|
app_secret_block: "app secret block hash"
|
||||||
|
path_prefix: "."
|
||||||
|
paths:
|
||||||
|
migrations: "db/migrations"
|
||||||
|
credentials: "config/credentials"
|
||||||
|
match_threshold: 3
|
||||||
|
nocodb:
|
||||||
|
api_token: test
|
||||||
|
base_url: https://example.com
|
||||||
|
employees_table_id: "1234567890"
|
||||||
|
infractions_table_id: "2468013579"
|
||||||
|
negative_infraction_id: 1
|
||||||
|
no_points_view_id: "1357924680"
|
||||||
|
oidc:
|
||||||
|
issuer: http://example.com
|
||||||
|
redirect_url: http://example.com
|
||||||
|
idms:
|
||||||
|
base_url: https://example.com
|
||||||
|
cars_group_id: "1234"
|
||||||
|
id: "1234"
|
||||||
|
password: "5678"
|
||||||
34
internal/config/version.go
Normal file
34
internal/config/version.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type Version struct {
|
||||||
|
Release string
|
||||||
|
Commit string
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
release = "dev"
|
||||||
|
commit = ""
|
||||||
|
date = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
func (v *Version) AppNameAndVersion(details bool) string {
|
||||||
|
result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release)
|
||||||
|
if details && v.Commit != "" {
|
||||||
|
result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit)
|
||||||
|
}
|
||||||
|
if details && v.Date != "" {
|
||||||
|
result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVersion() Version {
|
||||||
|
return Version{
|
||||||
|
Release: release,
|
||||||
|
Commit: commit,
|
||||||
|
Date: date,
|
||||||
|
}
|
||||||
|
}
|
||||||
103
internal/idms/request.go
Normal file
103
internal/idms/request.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package idms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"resty.dev/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Method string
|
||||||
|
|
||||||
|
const Get Method = "GET"
|
||||||
|
const Post Method = "POST"
|
||||||
|
const Put Method = "PUT"
|
||||||
|
const Delete Method = "Delete"
|
||||||
|
|
||||||
|
type ResponsePagination struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Current int `json:"current"`
|
||||||
|
EndIndex int `json:"end_index"`
|
||||||
|
Next int `json:"next"`
|
||||||
|
Previous int `json:"previous"`
|
||||||
|
StartIndex int `json:"start_index"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdmsResponse[T any] struct {
|
||||||
|
Pagination ResponsePagination `json:"pagination"`
|
||||||
|
Results []T `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdmsRequest[T any] struct {
|
||||||
|
Method Method
|
||||||
|
Path string
|
||||||
|
ResponseDecoder *IdmsResponse[T]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRestyRequest(url string, path string) *resty.Request {
|
||||||
|
client := resty.New()
|
||||||
|
formattedUrl := fmt.Sprintf("%s%s", url, path)
|
||||||
|
req := client.R()
|
||||||
|
req.SetURL(formattedUrl)
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetToken(config *config.ApplicationConfig) (string, error) {
|
||||||
|
req := newRestyRequest(config.Idms.BaseUrl, "/application/o/token/")
|
||||||
|
|
||||||
|
req.SetResult(&TokenResponse{})
|
||||||
|
req.SetMethod(string(Post))
|
||||||
|
req.SetFormData(map[string]string{
|
||||||
|
"client_id": config.Idms.Id.Value(),
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"password": config.Idms.Password.Value(),
|
||||||
|
"scope": "goauthentik.io/api",
|
||||||
|
"username": "service",
|
||||||
|
})
|
||||||
|
|
||||||
|
res, err := req.Send()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := res.Result().(*TokenResponse).AccessToken
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequest[T any](path string, method Method) IdmsRequest[T] {
|
||||||
|
return IdmsRequest[T]{
|
||||||
|
Method: method,
|
||||||
|
Path: path,
|
||||||
|
ResponseDecoder: &IdmsResponse[T]{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r IdmsRequest[T]) Perform(config *config.ApplicationConfig, bodyData interface{}, headers map[string]string) ([]T, error) {
|
||||||
|
req := newRestyRequest(config.Idms.BaseUrl, r.Path)
|
||||||
|
token, err := GetToken(config)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return []T{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.SetResult(r.ResponseDecoder).SetAuthToken(token).SetContentType("application/json")
|
||||||
|
req.SetMethod(string(r.Method))
|
||||||
|
res, err := req.Send()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return []T{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := res.Result().(*IdmsResponse[T]).Results
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
74
internal/idms/users.go
Normal file
74
internal/idms/users.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package idms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FlexibleString handles JSON values that can be either strings or numbers
|
||||||
|
type FlexibleString string
|
||||||
|
|
||||||
|
func (f *FlexibleString) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
|
*f = FlexibleString(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var n json.Number
|
||||||
|
if err := json.Unmarshal(data, &n); err == nil {
|
||||||
|
*f = FlexibleString(n.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot unmarshal %s into FlexibleString", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdmsUserAttributes struct {
|
||||||
|
Cars bool `json:"cars"`
|
||||||
|
EmployeeId FlexibleString `json:"employee_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdmsUser struct {
|
||||||
|
Attributes IdmsUserAttributes `json:"attributes"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
Id string `json:"uid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u IdmsUser) FirstName() string {
|
||||||
|
parts := strings.Fields(u.Name)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u IdmsUser) LastName() string {
|
||||||
|
parts := strings.Fields(u.Name)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCarsMembers(config *config.ApplicationConfig) []IdmsUser {
|
||||||
|
req := NewRequest[IdmsUser]("/api/v3/core/users/?is_active=true", Get)
|
||||||
|
results, _ := req.Perform(config, map[string]string{}, map[string]string{})
|
||||||
|
|
||||||
|
filtered := lo.Filter(results, func(u IdmsUser, _ int) bool {
|
||||||
|
return lo.Contains(u.Groups, config.Idms.CarsGroupId)
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
return filtered[i].LastName() < filtered[j].LastName()
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
13
internal/models/models_suite_test.go
Normal file
13
internal/models/models_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package models_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestModels(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Models Suite")
|
||||||
|
}
|
||||||
108
internal/nocodb/request.go
Normal file
108
internal/nocodb/request.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package nocodb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NocoDBRecord struct {
|
||||||
|
ID int `json:"ID"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NocoDBResponse represents the API response structure
|
||||||
|
type NocoDBResponse struct {
|
||||||
|
List []NocoDBRecord `json:"list"`
|
||||||
|
PageInfo struct {
|
||||||
|
TotalRows int `json:"totalRows"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
IsFirstPage bool `json:"isFirstPage"`
|
||||||
|
IsLastPage bool `json:"isLastPage"`
|
||||||
|
} `json:"pageInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NocoDBRequest struct {
|
||||||
|
EmployeeId int `json:"Employees_id"`
|
||||||
|
ReportedBy int `json:"Employees_id1"`
|
||||||
|
InfractionId int `json:"Infractions_id"`
|
||||||
|
Date string `json:"Date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NoPointsUrl(config *config.ApplicationConfig) string {
|
||||||
|
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.EmployeesTableId, "records")
|
||||||
|
return fmt.Sprintf("%s?viewId=%s", combinedUrl, config.NocoDBConfig.NoPointsViewId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddInfractionsUrl(config *config.ApplicationConfig) string {
|
||||||
|
combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.InfractionsTableId, "records")
|
||||||
|
return combinedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fetch(config *config.ApplicationConfig) ([]NocoDBRecord, error) {
|
||||||
|
records := []NocoDBRecord{}
|
||||||
|
offset := 0
|
||||||
|
limit := 25
|
||||||
|
isLastPage := false
|
||||||
|
|
||||||
|
baseURL := NoPointsUrl(config)
|
||||||
|
|
||||||
|
for !isLastPage {
|
||||||
|
// Create HTTP request with base URL
|
||||||
|
req, err := http.NewRequest("GET", baseURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set query params via the request's URL
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("offset", fmt.Sprintf("%d", offset))
|
||||||
|
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
// Add authorization header
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value()))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch data from API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check response status
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse response
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var nocoDBResp NocoDBResponse
|
||||||
|
if err := json.Unmarshal(body, &nocoDBResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JSON response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract names from records
|
||||||
|
records = append(records, nocoDBResp.List...)
|
||||||
|
|
||||||
|
// Check if we've reached the last page
|
||||||
|
isLastPage = nocoDBResp.PageInfo.IsLastPage
|
||||||
|
|
||||||
|
// Update offset for next page
|
||||||
|
offset += limit
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
64
internal/server/http.go
Normal file
64
internal/server/http.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware"
|
||||||
|
views_api "clintonambulance.com/calculate_negative_points/internal/api/requests"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
internal_web "clintonambulance.com/calculate_negative_points/internal/web"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/swaggest/openapi-go/openapi31"
|
||||||
|
"github.com/swaggest/rest/response"
|
||||||
|
"github.com/swaggest/rest/web"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResponder(w http.ResponseWriter, error string, code int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(ErrorResponse{Message: error})
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFoundResponder(w http.ResponseWriter, _r *http.Request) {
|
||||||
|
errorResponder(w, "Not Found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHttpServer(logger *zap.Logger, version config.Version) (*web.Service, *openapi31.Reflector) {
|
||||||
|
reflector := openapi31.NewReflector()
|
||||||
|
service := web.NewService(reflector)
|
||||||
|
|
||||||
|
service.OpenAPISchema().SetTitle("Calculate Negative Points")
|
||||||
|
service.OpenAPISchema().SetDescription("Easily maintain Calculate Negative Points")
|
||||||
|
service.OpenAPISchema().SetVersion("v1.0.0")
|
||||||
|
|
||||||
|
service.Use(
|
||||||
|
middleware.RealIP,
|
||||||
|
middleware.RequestID,
|
||||||
|
apimiddleware.AddRequestIDHeaderMiddleware,
|
||||||
|
apimiddleware.LoggingMiddleware(logger),
|
||||||
|
apimiddleware.ErrorHandler,
|
||||||
|
)
|
||||||
|
service.NotFound(notFoundResponder)
|
||||||
|
|
||||||
|
return service, reflector
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountAllEndpoints(srv *web.Service, version config.Version, config *config.ApplicationConfig, logger *zap.Logger) {
|
||||||
|
views_api.MountInternalApiEndpoints(srv, config, logger)
|
||||||
|
|
||||||
|
// This should be last because it does wildcard matching
|
||||||
|
internal_web.MountWebEndpoints(srv, config, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Override some global defaults:
|
||||||
|
// This is just "application/json" by default, let's include the charset as well.
|
||||||
|
response.DefaultSuccessResponseContentType = "application/json; charset=UTF-8"
|
||||||
|
}
|
||||||
51
internal/server/http_test.go
Normal file
51
internal/server/http_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Http Suite")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("NewHttpServer", func() {
|
||||||
|
It("Creates a new HttpServer", func() {
|
||||||
|
var logOutput bytes.Buffer
|
||||||
|
version := config.Version{Release: "test-version"}
|
||||||
|
logger, _ := config.NewLogger(version, &logOutput, []string{"cmd", "-e", "testEnvironment"})
|
||||||
|
srv, _ := server.NewHttpServer(logger, version)
|
||||||
|
srv.Method(http.MethodHead, "/dummy-route", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
// Validate that the server responds with the web frontend for unknown routes
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(rec.Header().Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
||||||
|
Expect(rec.Body.String()).To(ContainSubstring(`{"message":"Not Found"}`))
|
||||||
|
|
||||||
|
// Validate that it writes to the log for all requests
|
||||||
|
var logMessage map[string]interface{}
|
||||||
|
err := json.Unmarshal(logOutput.Bytes(), &logMessage)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(logMessage["msg"]).To(Equal("HTTP request processed"))
|
||||||
|
Expect(logMessage["method"]).To(Equal("GET"))
|
||||||
|
Expect(logMessage["uri"]).To(Equal("/foobar"))
|
||||||
|
Expect(logMessage["status"]).To(Equal(404.0))
|
||||||
|
})
|
||||||
|
})
|
||||||
130
internal/test/http_helper.go
Normal file
130
internal/test/http_helper.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/server"
|
||||||
|
"github.com/onsi/gomega/ghttp"
|
||||||
|
"github.com/swaggest/rest/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = config.Version{Release: "test-version", Commit: "test-commit"}
|
||||||
|
|
||||||
|
func CreateRequestBodyFromStruct(body interface{}) *bytes.Buffer {
|
||||||
|
return bytes.NewBuffer(CreateResponseBodyFromStruct(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateResponseBodyFromStruct(body interface{}) []byte {
|
||||||
|
jsonValue, err := json.Marshal(body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Error marshaling struct")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func URLFromServerAndPath(server *ghttp.Server, path string) string {
|
||||||
|
return fmt.Sprintf("%s%s", server.URL(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateHttpServer(appConfig *config.ApplicationConfig) *web.Service {
|
||||||
|
logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"})
|
||||||
|
defer flushLogs()
|
||||||
|
service, _ := server.NewHttpServer(logger, version)
|
||||||
|
server.MountAllEndpoints(service, version, appConfig, logger)
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClaims represents the mock user claims for testing
|
||||||
|
type TestClaims struct {
|
||||||
|
Sub string
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
Groups []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTestClaims returns default test user claims
|
||||||
|
func DefaultTestClaims() TestClaims {
|
||||||
|
return TestClaims{
|
||||||
|
Sub: "test-user-id",
|
||||||
|
Name: "Test User",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Groups: []string{"calculate-negative-points-users"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMockSessionCookie adds a mock session cookie to the request with the given claims
|
||||||
|
func addMockSessionCookie(req *http.Request, appConfig *config.ApplicationConfig) {
|
||||||
|
// Create a mock session with a fake id_token
|
||||||
|
session, _ := appConfig.CookieStore.New(req, appConfig.SessionName)
|
||||||
|
session.Values["id_token"] = "mock-test-token"
|
||||||
|
session.Values["refresh_token"] = "mock-refresh-token"
|
||||||
|
|
||||||
|
// Encode the session to a cookie
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
session.Save(req, rec)
|
||||||
|
|
||||||
|
// Copy the Set-Cookie header to the request as a Cookie header
|
||||||
|
for _, cookie := range rec.Result().Cookies() {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PerformHttpRequest(method string, path string, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
|
||||||
|
return PerformHttpRequestWithClaims(method, path, DefaultTestClaims(), requestParams...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PerformHttpRequestWithClaims(method string, path string, claims TestClaims, requestParams ...map[string]interface{}) *httptest.ResponseRecorder {
|
||||||
|
var body map[string]interface{}
|
||||||
|
headers := map[string]string{}
|
||||||
|
query := map[string]string{}
|
||||||
|
appConfig := CreateTestConfig()
|
||||||
|
srv := CreateHttpServer(appConfig)
|
||||||
|
|
||||||
|
for i, arg := range requestParams {
|
||||||
|
switch i {
|
||||||
|
case 0:
|
||||||
|
body = arg
|
||||||
|
case 1:
|
||||||
|
for header, value := range arg {
|
||||||
|
headers[header] = fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
for key, value := range arg {
|
||||||
|
query[key] = fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
panic("Unknown argument")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, path, CreateRequestBodyFromStruct(body))
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
for key, value := range query {
|
||||||
|
q.Add(key, value)
|
||||||
|
}
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
for header, value := range headers {
|
||||||
|
req.Header.Set(header, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mock session cookie for authentication
|
||||||
|
addMockSessionCookie(req, appConfig)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec
|
||||||
|
}
|
||||||
53
internal/test/test_helper.go
Normal file
53
internal/test/test_helper.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/config"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/onsi/ginkgo/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CreateTestConfig() *config.ApplicationConfig {
|
||||||
|
appSecret := "12345678901234567890123456789012"
|
||||||
|
appSecretBlock := "09876543210987654321098765431098"
|
||||||
|
|
||||||
|
basepath := os.Getenv("APP_MIGRATION_DIRECTORY")
|
||||||
|
if basepath == "" {
|
||||||
|
_, b, _, _ := runtime.Caller(0)
|
||||||
|
basepath = filepath.Dir(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config.ApplicationConfig{
|
||||||
|
AppSecret: config.SecretFromValue(appSecret),
|
||||||
|
AppSecretBlock: config.SecretFromValue(appSecretBlock),
|
||||||
|
CookieStore: sessions.NewCookieStore([]byte(appSecret)),
|
||||||
|
Environment: "test",
|
||||||
|
Listen: "127.0.0.1:3000",
|
||||||
|
MatchThreshold: 3,
|
||||||
|
SessionName: "calculate-negative-points-test",
|
||||||
|
NocoDBConfig: config.NocoDBConfig{
|
||||||
|
ApiToken: config.SecretFromValue("1234567890"),
|
||||||
|
BaseUrl: "https://example.com",
|
||||||
|
EmployeesTableId: "0987654321",
|
||||||
|
InfractionsTableId: "2468013579",
|
||||||
|
NegativeInfractionId: 1,
|
||||||
|
NoPointsViewId: "1357924680",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UnmarshalBody[K comparable, V any](rec *httptest.ResponseRecorder) map[K]V {
|
||||||
|
var body map[K]V
|
||||||
|
err := json.NewDecoder(rec.Body).Decode(&body)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ginkgo.Fail("Could not decode response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
28
internal/types/generic.go
Normal file
28
internal/types/generic.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
// PaginationRequest is a standard pagination input for API requests
|
||||||
|
type PaginationRequest struct {
|
||||||
|
Page int `json:"page" query:"page" default:"1" minimum:"1"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" default:"20" minimum:"1" maximum:"100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginationResponse is a standard pagination output for API responses
|
||||||
|
type PaginationResponse struct {
|
||||||
|
Page int `json:"page" required:"true"`
|
||||||
|
PageSize int `json:"page_size" required:"true"`
|
||||||
|
Total int `json:"total" required:"true"`
|
||||||
|
TotalPages int `json:"total_pages" required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedIndexResponse[T any] struct {
|
||||||
|
Items []T `json:"items" required:"true" nullable:"false"`
|
||||||
|
Pagination PaginationResponse `json:"pagination" required:"true" nullable:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IndexResponse[T any] struct {
|
||||||
|
Items []T `json:"items" required:"true" nullable:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShowResponse[T any] struct {
|
||||||
|
Item T `json:"item" required:"true" nullable:"false"`
|
||||||
|
}
|
||||||
9
internal/types/payroll_categories.go
Normal file
9
internal/types/payroll_categories.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type UiPayrollCategory struct {
|
||||||
|
Name string `json:"name" required:"true"`
|
||||||
|
Rate float64 `json:"rate" required:"true" format:"double"`
|
||||||
|
Id int `json:"id" required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiPayrollCategoriesResponse = IndexResponse[UiPayrollCategory]
|
||||||
11
internal/types/payroll_entries.go
Normal file
11
internal/types/payroll_entries.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type UiPayrollEntry struct {
|
||||||
|
Category UiPayrollCategory `json:"category" required:"true" nullable:"false"`
|
||||||
|
Date string `json:"date" format:"iso8601"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Employee UiUser `json:"employee" required:"true" nullable:"false"`
|
||||||
|
Quantity float64 `json:"quantity" format:"double" required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiPayrollEntriesResponse = PaginatedIndexResponse[UiPayrollEntry]
|
||||||
24
internal/types/users.go
Normal file
24
internal/types/users.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "clintonambulance.com/calculate_negative_points/internal/idms"
|
||||||
|
|
||||||
|
type UiUser struct {
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Groups []string `json:"groups,omitempty" required:"false"`
|
||||||
|
Id string `json:"id" required:"true" nullable:"false"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Name string `json:"name" required:"true" nullable:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiUsersResponse = IndexResponse[UiUser]
|
||||||
|
type UiUserResponse = ShowResponse[UiUser]
|
||||||
|
|
||||||
|
func UiUserFromIdmsUser(u idms.IdmsUser) UiUser {
|
||||||
|
return UiUser{
|
||||||
|
FirstName: u.FirstName(),
|
||||||
|
Groups: u.Groups,
|
||||||
|
Id: string(u.Attributes.EmployeeId),
|
||||||
|
LastName: u.LastName(),
|
||||||
|
Name: u.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/utils/convert_name_format.go
Normal file
13
internal/utils/convert_name_format.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func ConvertNameFormat(name string) string {
|
||||||
|
parts := strings.Split(name, ",")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
lastName := strings.TrimSpace(parts[0])
|
||||||
|
firstName := strings.TrimSpace(parts[1])
|
||||||
|
return firstName + " " + lastName
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
43
internal/utils/error.go
Normal file
43
internal/utils/error.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/swaggest/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema.
|
||||||
|
type CustomApplicationError interface {
|
||||||
|
rest.ErrWithHTTPStatus
|
||||||
|
CustomApplicationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplicationError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
code int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ CustomApplicationError = ApplicationError{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewApplicationError(message string, code int) ApplicationError {
|
||||||
|
return ApplicationError{
|
||||||
|
Message: message,
|
||||||
|
code: code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ApplicationError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ApplicationError) HTTPStatus() int {
|
||||||
|
return e.code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ApplicationError) CustomApplicationError() {}
|
||||||
|
|
||||||
|
func ValidationError(message string) ApplicationError {
|
||||||
|
return NewApplicationError(message, http.StatusUnprocessableEntity)
|
||||||
|
}
|
||||||
20
internal/utils/first_day_of_month.go
Normal file
20
internal/utils/first_day_of_month.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func FirstDayOfMonth() string {
|
||||||
|
// Load America/Detroit timezone
|
||||||
|
loc, err := time.LoadLocation("America/Detroit")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current time in Detroit timezone
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
|
||||||
|
// Create midnight of the first day of the current month
|
||||||
|
firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
// Return ISO8601 formatted string
|
||||||
|
return firstDay.Format(time.RFC3339)
|
||||||
|
}
|
||||||
34
internal/utils/levenshtein_distance.go
Normal file
34
internal/utils/levenshtein_distance.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func LevenshteinDistance(s1, s2 string) int {
|
||||||
|
s1 = strings.ToLower(s1)
|
||||||
|
s2 = strings.ToLower(s2)
|
||||||
|
|
||||||
|
len1 := len(s1)
|
||||||
|
len2 := len(s2)
|
||||||
|
|
||||||
|
matrix := make([][]int, len1+1)
|
||||||
|
for i := range matrix {
|
||||||
|
matrix[i] = make([]int, len2+1)
|
||||||
|
matrix[i][0] = i
|
||||||
|
}
|
||||||
|
for j := range matrix[0] {
|
||||||
|
matrix[0][j] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i <= len1; i++ {
|
||||||
|
for j := 1; j <= len2; j++ {
|
||||||
|
cost := 0
|
||||||
|
if s1[i-1] != s2[j-1] {
|
||||||
|
cost = 1
|
||||||
|
}
|
||||||
|
matrix[i][j] = min(
|
||||||
|
matrix[i-1][j]+1,
|
||||||
|
min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matrix[len1][len2]
|
||||||
|
}
|
||||||
19
internal/utils/logging.go
Normal file
19
internal/utils/logging.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) {
|
||||||
|
err := dbOp()
|
||||||
|
logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logMsg, err
|
||||||
|
}
|
||||||
12
internal/utils/must.go
Normal file
12
internal/utils/must.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "go.uber.org/zap"
|
||||||
|
|
||||||
|
func Must[T any](res T, err error) func(logger *zap.Logger) T {
|
||||||
|
return func(logger *zap.Logger) T {
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Fatal error", zap.Error(err))
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/utils/normalize_name.go
Normal file
23
internal/utils/normalize_name.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NormalizeName(name string) string {
|
||||||
|
// Remove extra whitespace
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
||||||
|
|
||||||
|
// Remove common punctuation but keep hyphens
|
||||||
|
name = strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' {
|
||||||
|
return unicode.ToLower(r)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, name)
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
105
internal/utils/parse_xls_file.go
Normal file
105
internal/utils/parse_xls_file.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Shift struct {
|
||||||
|
EarningCode string `json:"earning_code"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Hours float64 `json:"hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Shift) CountsAsWorked() bool {
|
||||||
|
return strings.ToLower(s.EarningCode) == "reg"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Employee struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Shifts []Shift `json:"shifts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Employee) Worked() bool {
|
||||||
|
return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) {
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open XLS file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read XLS file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseXLSContent(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseXLSContent parses the HTML content of an XLS file and returns a slice
|
||||||
|
// of employees with their shifts. The XLS files are HTML tables where:
|
||||||
|
// - Column A: employee name (td with class="smallbold" and valign=top)
|
||||||
|
// - Column B: inner table containing shift rows
|
||||||
|
// - Within the inner table, rows with colspan are category headers (skipped)
|
||||||
|
// - Shift rows have: spacer td, earning code/description td (class="smalltext"),
|
||||||
|
// and hours td
|
||||||
|
func ParseXLSContent(content []byte) ([]Employee, error) {
|
||||||
|
html := string(content)
|
||||||
|
|
||||||
|
// Split by employee rows in the outer table. Each employee row contains
|
||||||
|
// a name cell (column A) followed by a cell with an inner table (column B).
|
||||||
|
employeePattern := regexp.MustCompile(
|
||||||
|
`<td\s+valign=top\s+class="smallbold">([^<]+)</td>\s*<td>(.*?)</table></td>`,
|
||||||
|
)
|
||||||
|
employeeMatches := employeePattern.FindAllStringSubmatch(html, -1)
|
||||||
|
|
||||||
|
// Pattern for shift data rows: spacer td + earning code td + hours td
|
||||||
|
// These are rows where column C has class="smalltext" (not a colspan header)
|
||||||
|
shiftPattern := regexp.MustCompile(
|
||||||
|
`<tr><td width=25></td><td class=smalltext>([^<]+)</td><td[^>]*>([^<]+)</td></tr>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee {
|
||||||
|
name := strings.TrimSpace(empMatch[1])
|
||||||
|
innerTable := empMatch[2]
|
||||||
|
|
||||||
|
shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1)
|
||||||
|
|
||||||
|
shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) {
|
||||||
|
codeAndDesc := strings.TrimSpace(sm[1])
|
||||||
|
hoursStr := strings.TrimSpace(sm[2])
|
||||||
|
|
||||||
|
parts := strings.SplitN(codeAndDesc, " - ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return Shift{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
hours, err := strconv.ParseFloat(hoursStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return Shift{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Shift{
|
||||||
|
EarningCode: strings.TrimSpace(parts[0]),
|
||||||
|
Description: strings.TrimSpace(parts[1]),
|
||||||
|
Hours: hours,
|
||||||
|
}, true
|
||||||
|
})
|
||||||
|
|
||||||
|
return Employee{
|
||||||
|
Name: name,
|
||||||
|
Shifts: shifts,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return employees, nil
|
||||||
|
}
|
||||||
77
internal/utils/parse_xls_file_test.go
Normal file
77
internal/utils/parse_xls_file_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package utils_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"clintonambulance.com/calculate_negative_points/internal/utils"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("ParseXLSContent", func() {
|
||||||
|
It("parses employees and their shifts from the test XLS file", func() {
|
||||||
|
content, err := os.ReadFile("testdata/test.xls")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
employees, err := utils.ParseXLSContent(content)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(employees).To(HaveLen(2))
|
||||||
|
|
||||||
|
By("parsing the first employee")
|
||||||
|
Expect(employees[0].Name).To(Equal("Howe, Eugene"))
|
||||||
|
Expect(employees[0].Worked()).To(BeTrue())
|
||||||
|
Expect(employees[0].Shifts).To(HaveLen(2))
|
||||||
|
Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "MEET",
|
||||||
|
Description: "Meetings",
|
||||||
|
Hours: 1.75,
|
||||||
|
}))
|
||||||
|
Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse())
|
||||||
|
Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "Reg",
|
||||||
|
Description: "Regular Hours",
|
||||||
|
Hours: 125.75,
|
||||||
|
}))
|
||||||
|
Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue())
|
||||||
|
|
||||||
|
By("parsing the second employee with multiple shift categories")
|
||||||
|
Expect(employees[1].Name).To(Equal("User, Test"))
|
||||||
|
Expect(employees[1].Shifts).To(HaveLen(4))
|
||||||
|
Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "MEET",
|
||||||
|
Description: "Meetings",
|
||||||
|
Hours: 2.00,
|
||||||
|
}))
|
||||||
|
Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "Reg",
|
||||||
|
Description: "Regular Hours",
|
||||||
|
Hours: 134.00,
|
||||||
|
}))
|
||||||
|
Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "Holiday",
|
||||||
|
Description: "Holiday Hours",
|
||||||
|
Hours: 8.00,
|
||||||
|
}))
|
||||||
|
Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{
|
||||||
|
EarningCode: "PTO",
|
||||||
|
Description: "Paid Time Off",
|
||||||
|
Hours: 12.00,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips category header rows (colspan rows)", func() {
|
||||||
|
content, err := os.ReadFile("testdata/test.xls")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
employees, err := utils.ParseXLSContent(content)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(employees).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns an empty slice for empty content", func() {
|
||||||
|
employees, err := utils.ParseXLSContent([]byte(""))
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(employees).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
14
internal/utils/random_string.go
Normal file
14
internal/utils/random_string.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RandomString(n int) (string, error) {
|
||||||
|
bytes := make([]byte, n)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
11
internal/utils/schema/openfeature_flag.go
Normal file
11
internal/utils/schema/openfeature_flag.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package schemautils
|
||||||
|
|
||||||
|
type OpenfeatureFlag struct {
|
||||||
|
Name string `json:"name" required:"true" description:"Name of the flag"`
|
||||||
|
Id string `json:"id" required:"true" description:"UUID of the flag"`
|
||||||
|
Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"`
|
||||||
|
Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"`
|
||||||
|
Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"`
|
||||||
|
Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"`
|
||||||
|
Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"`
|
||||||
|
}
|
||||||
24
internal/utils/to_title_case.go
Normal file
24
internal/utils/to_title_case.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func ToTitleCase(name string) string {
|
||||||
|
words := strings.Fields(name)
|
||||||
|
for i, word := range words {
|
||||||
|
if len(word) > 0 {
|
||||||
|
// Handle hyphenated names
|
||||||
|
if strings.Contains(word, "-") {
|
||||||
|
parts := strings.Split(word, "-")
|
||||||
|
for j, part := range parts {
|
||||||
|
if len(part) > 0 {
|
||||||
|
parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
words[i] = strings.Join(parts, "-")
|
||||||
|
} else {
|
||||||
|
words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(words, " ")
|
||||||
|
}
|
||||||
13
internal/utils/utils_suite_test.go
Normal file
13
internal/utils/utils_suite_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package utils_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUtils(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Utils Suite")
|
||||||
|
}
|
||||||
37
internal/views/web.html
Normal file
37
internal/views/web.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Calculate Negative Points</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
You need to enable JavaScript to run this app.
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
{{ if .isDev }}
|
||||||
|
<script type="module">
|
||||||
|
import RefreshRuntime from "{{.baseAddress}}/@react-refresh"
|
||||||
|
RefreshRuntime.injectIntoGlobalHook(window)
|
||||||
|
window.$RefreshReg$ = () => {}
|
||||||
|
window.$RefreshSig$ = () => (type) => type
|
||||||
|
window.__vite_plugin_react_preamble_installed__ = true
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="module" src="{{.baseAddress}}/@vite/client"></script>
|
||||||
|
<script type="module" src="{{.jsFileAddress}}"></script>
|
||||||
|
{{ else }}
|
||||||
|
<script type="module" src="/{{.jsFileAddress}}"></script>
|
||||||
|
{{ range .css }}
|
||||||
|
<link rel="stylesheet" href="/{{.}}" />
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user