diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..9cb4d3a --- /dev/null +++ b/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 0 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "frontend"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..4d7cd18 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,34 @@ +name: Docker Build and Publish +on: + push: + branches: [main] + +jobs: + publish: + runs-on: ubuntu-latest + container: + image: ghcr.io/catthehacker/ubuntu:act-latest + steps: + - uses: https://github.com/actions/checkout@v4 + - name: Set up Docker Buildx + uses: https://github.com/docker/setup-buildx-action@v3 + env: + DOCKER_HOST: unix:///var/run/docker.sock + with: + config-inline: | + [registry."docker.office.clintonambulance.com"] + - name: Log in to Docker Registry + uses: https://github.com/docker/login-action@v3 + with: + registry: docker.office.clintonambulance.com + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and push Docker image + uses: https://github.com/docker/build-push-action@v5 + env: + DOCKER_HOST: unix:///var/run/docker.sock + with: + context: . + file: ./Dockerfile + push: true + tags: "docker.office.clintonambulance.com/calculate_negative_points:${{gitea.sha}},docker.office.clintonambulance.com/calculate_negative_points:latest" diff --git a/.gitignore b/.gitignore index 1dfff1b..b78f757 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,6 @@ *.log build/*.txt *.txt +*.xls -/tmp/ -inputs/ +/tmp/ \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index f645bcd..d5b2e8c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,3 @@ golang 1.25.6 +golangci-lint 2.8.0 +bun 1.3.8 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c98f086 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3779526 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM golang:1.25.6 AS gobuilder + +WORKDIR /opt/app-root + +COPY Makefile .tool-versions go.mod go.sum ./ +RUN --mount=type=tmpfs,target=/tmp \ + --mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \ + --mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \ + make mod + +COPY . /opt/app-root/ + +RUN --mount=type=tmpfs,target=/tmp \ + --mount=type=cache,target=/go/pkg/mod,id=calculate-negative-points-go1256-mod-cache \ + --mount=type=cache,target=/root/.cache/go-build,id=calculate-negative-points-go1256-build-cache \ + CGO_ENABLED="0" BINDIR="/usr/local/bin" make clean; CGO_ENABLED="0" BINDIR="/usr/local/bin" make build + +FROM oven/bun:1.3.8 AS bunbuilder + +ENV NODE_ENV="production" + +COPY . /opt/app-root + +WORKDIR /opt/app-root/frontend + +RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/log \ + --mount=type=cache,target=/usr/local/share/.cache/yarn,id=calculate-negative-points-bun138-build-share-yarn-cache \ + --mount=type=cache,target=/opt/app-root/frontend/node_modules,id=calculate-negative-points-bun138-build-app-node-modules \ + bun install; bunx --bun vite build + +FROM golang:1.25.6 AS final + +WORKDIR $NON_ROOT_USER_HOME +COPY --from=bunbuilder /opt/app-root/public /opt/app-root/public +COPY --from=gobuilder /usr/local/bin/calculate_negative_points /usr/local/bin/calculate_negative_points +COPY --from=gobuilder /opt/app-root/internal/views /opt/app-root/views +COPY --from=gobuilder /opt/app-root/db/migrations /opt/app-root/migrations +COPY config/ /opt/app-root/config + +ENV TZ=America/Detroit +EXPOSE 3000 +CMD ["/usr/local/bin/calculate_negative_points", "--listen", "0.0.0.0:3000"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0d7f239 --- /dev/null +++ b/Makefile @@ -0,0 +1,100 @@ +# Ignore built-in rules, these do not apply for Go builds +MAKEFLAGS += --no-builtin-rules + +# Ignore the default suffixes, in this automation script all names refer to actual tasks and files +.SUFFIXES: + +.PHONY: all +all: check test build + +### +### Pick the right Go SDK if several are available +### + +GO_SDK_VERSION ?= $(shell \awk '/^golang [0-9]/ { print $$2 }' .tool-versions) +ifneq (,$(shell command -v go$(GO_SDK_VERSION))) +# Use the selected SDK +GOVERSION ?= go$(GO_SDK_VERSION) +else +# No SDK in the PATH, fall back on whatever version is available +GOVERSION := go +endif + +ifeq ($(OS),Darwin) +# Use the selected SDK +LINTOS ?= darwin +else +# No SDK in the PATH, fall back on whatever version is available +LINTOS := linux +endif + +ifeq ($(shell uname -p),aarch64) +COERCED_ARCH = arm64 +else +COERCED_ARCH = amd64 +endif + +export BUILD_DATE ?= $(shell \date -u '+%FT%TZ') +export GIT_COMMIT ?= $(shell git describe --match=NeVeRmAtCh --always --abbrev=7 --dirty='*') +export APP_MAJOR_MINOR_VERSION ?= $(shell \grep -m1 -o -E '^[0-9]+\.[0-9]+' VERSION) +export APP_PATCH_VERSION ?= dev +export APP_VERSION ?= $(APP_MAJOR_MINOR_VERSION).$(APP_PATCH_VERSION) + +CONFIG_PACKAGE := clintonambulance.com/calculate_negative_points/internal/config +LDFLAGS_VERSION := -X $(CONFIG_PACKAGE).release=$(APP_VERSION) -X $(CONFIG_PACKAGE).date=$(BUILD_DATE) -X $(CONFIG_PACKAGE).commit=$(GIT_COMMIT) +LDFLAGS := -ldflags "$(LDFLAGS_VERSION) $(LDFLAGS_EXTRA)" + +### +### Output directory +### + +BINDIR ?= $(shell pwd)/bin +$(BINDIR): + install -d $(BINDIR) + +.PHONY: mod +mod: + @printf "\nRunning go mod...\n" + $(GOVERSION) mod verify + $(GOVERSION) mod download + @printf "Complete.\n" + +.PHONY: check +check: GOFMTCHECK := $(shell gofmt -s -d .) +check: + @printf "\nRunning checks...\n" + @echo - gofmt + @[[ "$(GOFMTCHECK)" == "" ]] || (echo "FAILED: gofmt failed" ; exit 1) + @echo - golangci-lint + @golangci-lint run + @printf "All checks PASSED.\n" + +.PHONY: test +test: + @printf "\nRunning tests...\n" + ginkgo -r + @printf "Tests PASSED.\n" + +test-until-fail: + @printf "\nRunning tests until something fails...\n" + ginkgo -r --until-it-fails + +.PHONY: clean +clean: $(BINDIR) + @printf "\nCleaning output files...\n" + rm -f $(BINDIR)/calculate_negative_points $(BINDIR)/sync_from_samsara + +.PHONY: buildbins +buildbins: $(BINDIR) + @printf "\nRunning build...\n" + $(GOVERSION) build $(LDFLAGS) -trimpath -o $(BINDIR)/calculate_negative_points + @printf "Build complete.\n" + +.PHONY: build +build: clean buildbins + +docs/openapi/api.yaml: $(shell find . -name '*.go') + @printf "\nBuilding OpenAPI document...\n" + $(GOVERSION) run $(LDFLAGS) cmd/generate-api-docs/main.go -e testEnvironment -c internal/config/testdata/settings.test.yml + @printf "\nGenerating API types...\n" + cd frontend && bun run generate-api-types \ No newline at end of file diff --git a/cmd/app.go b/cmd/app.go new file mode 100644 index 0000000..69b8e40 --- /dev/null +++ b/cmd/app.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "net/http" + "os" + "strings" + + apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware" + "clintonambulance.com/calculate_negative_points/internal/config" + "clintonambulance.com/calculate_negative_points/internal/server" + "clintonambulance.com/calculate_negative_points/internal/utils" + "github.com/go-chi/chi/v5" + "github.com/swaggest/rest/web" + "go.uber.org/zap" +) + +func Execute(version config.Version) { + logger, flushLogs := config.NewLogger(version, os.Stdout, os.Args) + defer flushLogs() + + configuration := utils.Must(config.Load(version, os.Exit, os.Args, logger))(logger) + + logger.Info("Application startup", zap.Any("config", configuration), zap.Any("full_version", version)) + + srv, _ := server.NewHttpServer(logger, version) + + FileServer(srv, "/assets/", configuration.PublicPath, logger) + + server.MountAllEndpoints(srv, version, configuration, logger) + + srv.NotFound(func(w http.ResponseWriter, _ *http.Request) { + apimiddleware.ErrorResponder(w, "Not Found", http.StatusNotFound) + }) + srv.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + apimiddleware.ErrorResponder(w, "Method Not Allowed", http.StatusMethodNotAllowed) + }) + + if err := http.ListenAndServe(configuration.Listen, srv); err != nil { + logger.Fatal("HTTP server error", zap.Error(err)) + } +} + +func FileServer(r *web.Service, path string, rootPath string, logger *zap.Logger) { + root := http.Dir(rootPath) + if strings.ContainsAny(path, "{}*") { + panic("FileServer does not permit any URL parameters.") + } + path += "*" + + r.Wrapper.Get(path, func(w http.ResponseWriter, r *http.Request) { + rctx := chi.RouteContext(r.Context()) + pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*") + logger.Info("Serving file", zap.String("root_path", rootPath), zap.String("pathPrefix", pathPrefix), zap.String("rawPath", r.URL.Path)) + fs := http.FileServer(root) + + fs.ServeHTTP(w, r) + }) +} diff --git a/cmd/generate-api-docs/main.go b/cmd/generate-api-docs/main.go new file mode 100644 index 0000000..7dcfe06 --- /dev/null +++ b/cmd/generate-api-docs/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + "clintonambulance.com/calculate_negative_points/internal/config" + "clintonambulance.com/calculate_negative_points/internal/server" + "go.uber.org/zap" +) + +func main() { + version := config.NewVersion() + logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment", "-c", "internal/config/testdata/settings.test.yml"}) + defer flushLogs() + + configuration, err := config.Load(version, os.Exit, os.Args, logger) + + if err != nil { + panic(err) + } + + srv, reflector := server.NewHttpServer(logger, version) + server.MountAllEndpoints(srv, version, configuration, logger) + + //schema, err := srv.OpenAPI.MarshalYAML() + schema, err := reflector.Spec.MarshalYAML() + if err != nil { + panic(err) + } + outputFile := "docs/openapi/api.yaml" + if err := os.WriteFile(outputFile, schema, 0644); err != nil { + panic(err) + } + logger.Info("Generated OpenAPI docs", zap.String("output", outputFile)) +} diff --git a/config/credentials/dev.enc b/config/credentials/dev.enc new file mode 120000 index 0000000..4dc4780 --- /dev/null +++ b/config/credentials/dev.enc @@ -0,0 +1 @@ +development.enc \ No newline at end of file diff --git a/config/credentials/dev.key b/config/credentials/dev.key new file mode 120000 index 0000000..ef53c05 --- /dev/null +++ b/config/credentials/dev.key @@ -0,0 +1 @@ +development.key \ No newline at end of file diff --git a/config/credentials/development.enc b/config/credentials/development.enc new file mode 100644 index 0000000..b9543a1 --- /dev/null +++ b/config/credentials/development.enc @@ -0,0 +1 @@ +43632cedb614ccfcd5fc7362289eb48103bb5d36e9293299bef71362f2ac0b4ae185b9911e0d9d8c50ade436c7f688991719a1cd6b4339aaf91a9ad3c5646b15e5f4f59f127bf9f038623af497d703dce6e38576bda2f41df57c66845bca1cd84430cbdad6d8c2c55564cd01926dbb368bb6328047e1269626478563783add3134618c10fba4735639bc5d0f517192766a843ef04288ea9f7a4f20a2df2f24eaad562f7762b28546e9ecc9fcb462db8eedc2dcc9981952a1a6b9d4654eb0ced0dd5b662904647b5e64163a40289bb780db5333e0154319d0cb67b2aec127bfc1e570b34410078ceb605cc7fb668cd082e326d68d7eb1177ad50058e0053eeefd14c6a59809d5934241e9cee8392b5951f666ff416088731b1ba88bb648dd48e87ddd7585c3a5e4e5e46431c9b0137a8ac2bdb0512fa4ecfc1be3bc59d80682bf6d85b872f2c32740e5cff94614b067f09d9a987d0dcf4b0478d6286b9da1084af7ed6d1dc428047ac4580c216a363c1e10d9a11cb5068f42d5bbd63014ceb83009228a4d41796ceb1432298f5b273dbca63e2f5a0f7d3572b1374cdf85d28a71706aa553d1a2b31aabbcad271c6be6d7d9d3b4cc0693314e08c8789678c6f086984f4f9d3cc7a86c5020a35498944693b7c6d1929b40e4b962f71b051b2f732657d3e8544a789094c214482ae8c823612f17e450e3c463c52335ffa900f0ab7a8cc0abd9c38fc5d9c997b747==--==66d2a6c19fe7d7a40ff1417d \ No newline at end of file diff --git a/config/credentials/development.key b/config/credentials/development.key new file mode 100644 index 0000000..865f542 --- /dev/null +++ b/config/credentials/development.key @@ -0,0 +1 @@ +f7e00100b7db4e78aaf5c622271476dc31e47478c49c346d7d9e9ed2a9ee0289 \ No newline at end of file diff --git a/config/credentials/production.enc b/config/credentials/production.enc new file mode 120000 index 0000000..4dc4780 --- /dev/null +++ b/config/credentials/production.enc @@ -0,0 +1 @@ +development.enc \ No newline at end of file diff --git a/config/credentials/production.key b/config/credentials/production.key new file mode 120000 index 0000000..ef53c05 --- /dev/null +++ b/config/credentials/production.key @@ -0,0 +1 @@ +development.key \ No newline at end of file diff --git a/config/credentials/testEnvironment.enc b/config/credentials/testEnvironment.enc new file mode 100644 index 0000000..46e2e94 --- /dev/null +++ b/config/credentials/testEnvironment.enc @@ -0,0 +1 @@ +9f545b6e36205feadbbc6b788963c4efa3fb2179c9432ca4a14c40b4f2a06b2b0a086cbd987db98e55243f56158702e8974a7e1323c0694c2b572e77f468112dc675057d11b041f042cf310a2548c8f22b927f8b3da42e7ba326f15f4bd0a69b6541192acd09b79d47effbfad3c0639d067258684743e727342bbe78a036292b56436a50513b6a630b6e8321d6862bbd16e015e457ff8b28d1a44de2aca2f9cbc42ba0b19bfbe56106860222fc5cd58e0ab8e42d==--==cd1cef336de7155de2fbd0fc \ No newline at end of file diff --git a/config/credentials/testEnvironment.key b/config/credentials/testEnvironment.key new file mode 100644 index 0000000..662c926 --- /dev/null +++ b/config/credentials/testEnvironment.key @@ -0,0 +1 @@ +58c2f85a0c5b7ee2a9d5b96773d06700dd0c40a0f6a09f5e33fa5c0ce85a4bf0 \ No newline at end of file diff --git a/config/development.local.yml b/config/development.local.yml new file mode 100644 index 0000000..21c9b24 --- /dev/null +++ b/config/development.local.yml @@ -0,0 +1,13 @@ +database: + username: "eugene" + ssl_mode: "disable" +app_secret: "0a7b29dd092dd09ac0ea30c5ba59baaf" +app_secret_block: "a55152f70362669c6ea18f17f593ff3b" +path_prefix: "." +paths: + credentials: "config/credentials" + migrations: "db/migrations" + public: "public" + views: "internal/views" +oidc: + redirect_url: "http://localhost:3000" \ No newline at end of file diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000..4bb47ed --- /dev/null +++ b/config/settings.yml @@ -0,0 +1,26 @@ +app_secret: "override_me" +app_secret_block: "override_me" +path_prefix: "/opt/app-root" +paths: + credentials: "config/credentials" + migrations: "migrations" + public: "public" + views: "views" +idms: + base_url: "https://identity.office.clintonambulance.com" + cars_group_id: "01175d60-1a2c-4255-8976-32e6cb205341" +match_threshold: 3 +nocodb: + base_url: https://sheets.office.clintonambulance.com + employees_table_id: "m12l1ptaalz9ciq" + infractions_table_id: "myr46d3wdd4hhyg" + negative_infraction_id: 9 + no_points_view_id: "vwjtzv5npqodcy71" +oidc: + issuer: "https://identity.office.clintonambulance.com/application/o/calculate-negative-points/" + redirect_url: "https://calculatenegativepoints.office.clintonambulance.com" + scopes: + - openid + - profile + - email + - offline_access \ No newline at end of file diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml new file mode 100644 index 0000000..426ee7b --- /dev/null +++ b/docs/openapi/api.yaml @@ -0,0 +1,102 @@ +openapi: 3.1.0 +info: + description: Easily maintain Calculate Negative Points + title: Calculate Negative Points + version: v1.0.0 +paths: + /api/process: + post: + description: Process Negative Points + operationId: requests/negative_points_processor.NegativePointsProcessor + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FormDataNegativePointsProcessorNegativePointsProcessorInput' + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/NegativePointsProcessorNegativePointsProcessorOutput' + description: OK + summary: Negative Points Processor + tags: + - Negative Points Processor + /api/users/current: + delete: + description: Logout current user + operationId: requests/users.Logout + responses: + "200": + content: + application/json: + schema: + additionalProperties: {} + type: + - "null" + - object + description: OK + summary: Logout + tags: + - Users + get: + description: Retrieve the current user + operationId: requests/users.GetCurrentUser + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser' + description: OK + summary: Get Current User + tags: + - Users +components: + schemas: + FormDataNegativePointsProcessorNegativePointsProcessorInput: + properties: + file: + $ref: '#/components/schemas/MultipartFileHeader' + description: XLS schedule file to process + type: object + MultipartFileHeader: + contentMediaType: application/octet-stream + format: binary + type: string + NegativePointsProcessorNegativePointsProcessorOutput: + properties: + employees: + description: List of employees who had negative points + items: + type: string + type: array + required: + - employees + type: object + TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser: + properties: + item: + $ref: '#/components/schemas/TypesUiUser' + required: + - item + type: object + TypesUiUser: + properties: + first_name: + type: string + groups: + items: + type: string + type: array + id: + type: string + last_name: + type: string + name: + type: string + required: + - id + - name + type: object diff --git a/find_overlaps.go b/find_overlaps.go deleted file mode 100644 index d8b6eff..0000000 --- a/find_overlaps.go +++ /dev/null @@ -1,369 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "time" - "unicode" - - "github.com/samber/lo" -) - -const EugeneId = 23 -const NegativeInfractionId = 9 -const BaseUrl = "https://sheets.office.clintonambulance.com" -const EmployeesTableId = "m12l1ptaalz9ciq" -const NoPointsViewId = "vwjtzv5npqodcy71" -const InfractionsTableId = "myr46d3wdd4hhyg" - -// NocoDBRecord represents a record from the NocoDB API -type NocoDBRecord struct { - ID int `json:"ID"` - Name string `json:"Name"` -} - -// NocoDBResponse represents the API response structure -type NocoDBResponse struct { - List []NocoDBRecord `json:"list"` - PageInfo struct { - TotalRows int `json:"totalRows"` - Page int `json:"page"` - PageSize int `json:"pageSize"` - IsFirstPage bool `json:"isFirstPage"` - IsLastPage bool `json:"isLastPage"` - } `json:"pageInfo"` -} - -type NocoDBRequest struct { - EmployeeId int `json:"Employees_id"` - ReportedBy int `json:"Employees_id1"` - InfractionId int `json:"Infractions_id"` - Date string `json:"Date"` -} - -func noPointsUrl() string { - combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", EmployeesTableId, "records") - return fmt.Sprintf("%s?viewId=%s", combinedUrl, NoPointsViewId) -} - -func addInfractionsUrl() string { - combinedUrl, _ := url.JoinPath(BaseUrl, "api/v2/tables", InfractionsTableId, "records") - return combinedUrl -} - -func firstDayOfMonth() string { - // Load America/Detroit timezone - loc, err := time.LoadLocation("America/Detroit") - if err != nil { - panic(err) - } - - // Get current time in Detroit timezone - now := time.Now().In(loc) - - // Create midnight of the first day of the current month - firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) - - // Return ISO8601 formatted string - return firstDay.Format(time.RFC3339) -} - -// levenshteinDistance calculates the edit distance between two strings -func levenshteinDistance(s1, s2 string) int { - s1 = strings.ToLower(s1) - s2 = strings.ToLower(s2) - - len1 := len(s1) - len2 := len(s2) - - matrix := make([][]int, len1+1) - for i := range matrix { - matrix[i] = make([]int, len2+1) - matrix[i][0] = i - } - for j := range matrix[0] { - matrix[0][j] = j - } - - for i := 1; i <= len1; i++ { - for j := 1; j <= len2; j++ { - cost := 0 - if s1[i-1] != s2[j-1] { - cost = 1 - } - matrix[i][j] = min( - matrix[i-1][j]+1, - min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost), - ) - } - } - return matrix[len1][len2] -} - -// normalizeName removes extra whitespace and punctuation, converts to lowercase -func normalizeName(name string) string { - // Remove extra whitespace - name = strings.TrimSpace(name) - name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ") - - // Remove common punctuation but keep hyphens - name = strings.Map(func(r rune) rune { - if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' { - return unicode.ToLower(r) - } - return -1 - }, name) - - return name -} - -// convertNameFormat converts "LastName, FirstName" to "FirstName LastName" -func convertNameFormat(name string) string { - parts := strings.Split(name, ",") - if len(parts) == 2 { - lastName := strings.TrimSpace(parts[0]) - firstName := strings.TrimSpace(parts[1]) - return firstName + " " + lastName - } - return name -} - -// toTitleCase converts a name to Title Case -func toTitleCase(name string) string { - words := strings.Fields(name) - for i, word := range words { - if len(word) > 0 { - // Handle hyphenated names - if strings.Contains(word, "-") { - parts := strings.Split(word, "-") - for j, part := range parts { - if len(part) > 0 { - parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:]) - } - } - words[i] = strings.Join(parts, "-") - } else { - words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) - } - } - } - return strings.Join(words, " ") -} - -// parseXLSFile extracts names from the HTML-formatted XLS file -func parseXLSFile(filename string) ([]string, error) { - content, err := os.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("failed to read XLS file: %w", err) - } - - // Extract names from HTML table cells with class="smallbold" - // Pattern matches: NAME - pattern := regexp.MustCompile(`]*class="smallbold"[^>]*>([^<]+)`) - matches := pattern.FindAllStringSubmatch(string(content), -1) - - names := make([]string, 0) - seen := make(map[string]bool) - - for _, match := range matches { - if len(match) > 1 { - name := strings.TrimSpace(match[1]) - // Filter out non-name entries (like "Regular Shift", "Total:", etc.) - if name != "" && !strings.Contains(name, "Total") && - !strings.Contains(name, "Shift") && !strings.Contains(name, ":") && - strings.Contains(name, ",") { // Names should have comma - normalized := normalizeName(name) - if !seen[normalized] { - seen[normalized] = true - names = append(names, name) - } - } - } - } - - return names, nil -} - -// fetchNamesFromAPI fetches names from the NocoDB API -func fetchFromAPI(apiURL, apiToken string) ([]NocoDBRecord, error) { - records := []NocoDBRecord{} - offset := 0 - limit := 25 - isLastPage := false - - for !isLastPage { - // Build URL with pagination - url := fmt.Sprintf("%s&offset=%d&limit=%d", apiURL, offset, limit) - - // Create HTTP request - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add authorization header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiToken)) - req.Header.Set("Content-Type", "application/json") - - // Make the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch data from API: %w", err) - } - defer resp.Body.Close() - - // Check response status - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - // Read and parse response - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var nocoDBResp NocoDBResponse - if err := json.Unmarshal(body, &nocoDBResp); err != nil { - return nil, fmt.Errorf("failed to parse JSON response: %w", err) - } - - // Extract names from records - records = append(records, nocoDBResp.List...) - - // Check if we've reached the last page - isLastPage = nocoDBResp.PageInfo.IsLastPage - - // Update offset for next page - offset += limit - } - - return records, nil -} - -func hasMatch(record NocoDBRecord, candidates []string, threshold int) bool { - normalizedName := normalizeName(toTitleCase(record.Name)) - bestDistance := threshold + 1 - - for _, candidate := range candidates { - normalizedCandidate := normalizeName(candidate) - distance := levenshteinDistance(normalizedName, normalizedCandidate) - - if distance < bestDistance { - bestDistance = distance - } - } - - if bestDistance <= threshold { - return true - } - return false -} - -func main() { - // Define command-line flags - xlsFile := flag.String("xls", "", "Path to the XLS file (required)") - apiToken := flag.String("api-token", "", "NocoDB API token (or set NOCODB_API_TOKEN environment variable)") - threshold := flag.Int("threshold", 3, "Maximum edit distance for fuzzy matching") - - flag.Parse() - - // Get API token from environment if not provided via flag - token := *apiToken - if token == "" { - token = os.Getenv("NOCODB_API_TOKEN") - } - - // Validate required arguments - if *xlsFile == "" { - fmt.Println("Error: -xls flag is required") - fmt.Println("\nUsage:") - flag.PrintDefaults() - os.Exit(1) - } - - if token == "" { - fmt.Println("Error: API token is required") - fmt.Println("Provide it via -api-token flag or NOCODB_API_TOKEN environment variable") - fmt.Println("\nUsage:") - flag.PrintDefaults() - os.Exit(1) - } - - xlsNames, err := parseXLSFile(*xlsFile) - if err != nil { - fmt.Printf("Error parsing XLS file: %v\n", err) - os.Exit(1) - } - - records, err := fetchFromAPI(noPointsUrl(), token) - if err != nil { - fmt.Printf("Error fetching data from API: %v\n", err) - os.Exit(1) - } - - // Convert XLS names to same format as API names (FirstName LastName) and normalize casing - xlsNamesConverted := make([]string, len(xlsNames)) - for i, name := range xlsNames { - converted := convertNameFormat(name) - xlsNamesConverted[i] = toTitleCase(converted) - } - - overlaps := lo.Filter(records, func(r NocoDBRecord, _ int) bool { - return hasMatch(r, xlsNamesConverted, *threshold) - }) - - requestObjects := lo.Map(overlaps, func(r NocoDBRecord, _ int) NocoDBRequest { - return NocoDBRequest{ - EmployeeId: r.ID, - ReportedBy: EugeneId, - InfractionId: NegativeInfractionId, - Date: firstDayOfMonth(), - } - }) - - // Marshal request objects to JSON - jsonData, err := json.Marshal(requestObjects) - if err != nil { - fmt.Printf("Error marshaling request objects: %v\n", err) - os.Exit(1) - } - - // Create POST request - req, err := http.NewRequest("POST", addInfractionsUrl(), strings.NewReader(string(jsonData))) - if err != nil { - fmt.Printf("Error creating POST request: %v\n", err) - os.Exit(1) - } - - // Add authorization header - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - req.Header.Set("Content-Type", "application/json") - - // Make the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Printf("Error submitting request: %v\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - // Check response status - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - body, _ := io.ReadAll(resp.Body) - fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body)) - os.Exit(1) - } - - fmt.Printf("Successfully submitted %d records\n", len(requestObjects)) -} diff --git a/frontend/.env b/frontend/.env new file mode 120000 index 0000000..4a82335 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +../.env \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..da98444 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..628d332 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + + Vite + React + TS + + + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a6c6409 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,59 @@ +{ + "name": "calculate_negative_points", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "bunx --bun vite", + "build": "bunx --bun vite build --emptyOutDir", + "lint": "bunx --bun eslint .", + "typecheck": "bunx --bun tsc --noEmit", + "generate-api-types": "bunx --bun openapi-typescript ../docs/openapi/api.yaml --output ./src/api/generated/schema.ts", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-form": "^1.28.0", + "@tanstack/react-query": "^5.79.2", + "@tanstack/react-router": "^1.120.12", + "@types/lodash": "^4.17.17", + "axios": "^1.9.0", + "bootstrap": "^5.3.6", + "classnames": "^2.5.1", + "date-fns": "^4.1.0", + "flatpickr": "^4.6.13", + "framer-motion": "^12.16.0", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "oidc-client-ts": "^3.2.1", + "pluralize": "^8.0.0", + "react": "^19.1.0", + "react-bootstrap-date-picker": "^5.1.0", + "react-dom": "^19.1.0", + "react-flatpickr": "^4.0.10", + "react-oidc-context": "^3.3.0", + "reactstrap": "^9.2.3", + "sass": "^1.89.2" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@rollup/plugin-image": "^3.0.3", + "@tanstack/react-router-devtools": "^1.120.12", + "@tanstack/router-plugin": "^1.120.12", + "@types/classnames": "^2.3.4", + "@types/flatpickr": "^3.1.4", + "@types/js-cookie": "^3.0.6", + "@types/pluralize": "^0.0.33", + "@types/react": "^19.1.2", + "@types/react-bootstrap-date-picker": "^4.0.12", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react-swc": "^3.9.0", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "openapi-typescript": "^7.8.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/api/generated/schema.ts b/frontend/src/api/generated/schema.ts new file mode 100644 index 0000000..05ed46e --- /dev/null +++ b/frontend/src/api/generated/schema.ts @@ -0,0 +1,150 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/process": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Negative Points Processor + * @description Process Negative Points + */ + post: operations["requests/negative_points_processor.NegativePointsProcessor"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Current User + * @description Retrieve the current user + */ + get: operations["requests/users.GetCurrentUser"]; + put?: never; + post?: never; + /** + * Logout + * @description Logout current user + */ + delete: operations["requests/users.Logout"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + FormDataNegativePointsProcessorNegativePointsProcessorInput: { + /** @description XLS schedule file to process */ + file?: components["schemas"]["MultipartFileHeader"]; + }; + /** Format: binary */ + MultipartFileHeader: string; + NegativePointsProcessorNegativePointsProcessorOutput: { + /** @description List of employees who had negative points */ + employees: string[]; + }; + TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser: { + item: components["schemas"]["TypesUiUser"]; + }; + TypesUiUser: { + first_name?: string; + groups?: string[]; + id: string; + last_name?: string; + name: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + "requests/negative_points_processor.NegativePointsProcessor": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "multipart/form-data": components["schemas"]["FormDataNegativePointsProcessorNegativePointsProcessorInput"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NegativePointsProcessorNegativePointsProcessorOutput"]; + }; + }; + }; + }; + "requests/users.GetCurrentUser": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser"]; + }; + }; + }; + }; + "requests/users.Logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": null | { + [key: string]: unknown; + }; + }; + }; + }; + }; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..49d7332 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,3 @@ +export * from "./request"; +export * from "./users"; +export * from "./process"; diff --git a/frontend/src/api/process.ts b/frontend/src/api/process.ts new file mode 100644 index 0000000..37d413d --- /dev/null +++ b/frontend/src/api/process.ts @@ -0,0 +1,13 @@ +import * as schema from "./generated/schema"; +import { request } from "@/api"; + +type ProcessPath = schema.paths["/api/process"]; +type ProcessResponse = + ProcessPath["post"]["responses"]["200"]["content"]["application/json"]; + +export const processFile = async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + + return await request.postFormData("/api/process", formData); +}; diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..b1a38a6 --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,121 @@ +import axios, { AxiosError, AxiosPromise } from "axios"; +const jsonHeaders = (additional?: { [x: string]: string }) => ({ + Accept: "application/json", + "Content-Type": "application/json", + ...additional, +}); + +const withUnauthorizedRedirect = async (axiosFn: () => AxiosPromise) => { + try { + const res = await axiosFn(); + + return res; + } catch (e: unknown) { + const status = (e as AxiosError).response?.status; + + if (status && [401, 403].includes(status)) { + window.location.href = `/auth/login?returnTo=${window.location.href}`; + } + + throw e; + } +}; + +type GetRequest = { url: string } & ( + | { + pagination?: false; + } + | { + pagination: true; + page: number; + page_size: number; + } +); + +export const request = { + getUnauthenticated: async (url: string): Promise => { + const { data } = await axios.get(url, { + headers: jsonHeaders(), + withCredentials: true, + }); + return data; + }, + get: async (request: GetRequest): Promise => { + const params = + request.pagination === true + ? { page: request.page, page_size: request.page_size } + : undefined; + const { data } = await withUnauthorizedRedirect(() => + axios.get(request.url, { + headers: jsonHeaders(), + params, + withCredentials: true, + }), + ); + return data; + }, + put: async (url: string, body: T | null): Promise => { + const { data } = await withUnauthorizedRedirect(() => { + return axios.put(url, body, { + headers: jsonHeaders(), + withCredentials: true, + }); + }); + return data; + }, + post: async (url: string, body: T): Promise => { + const { data } = await withUnauthorizedRedirect(() => { + return axios.post(url, body, { + headers: jsonHeaders(), + withCredentials: true, + }); + }); + return data; + }, + delete: async (url: string): Promise => { + console.log(url); + const { data } = await withUnauthorizedRedirect(() => { + return axios.delete(url, { + headers: jsonHeaders(), + withCredentials: true, + }); + }); + return data; + }, + postFormData: async (url: string, body: FormData): Promise => { + const { data } = await withUnauthorizedRedirect(() => { + return axios.post(url, body, { + headers: { Accept: "application/json" }, + withCredentials: true, + }); + }); + return data; + }, + download: async (url: string, body: T) => { + const response = await withUnauthorizedRedirect(() => { + return axios.post(url, body, { + responseType: "blob", + withCredentials: true, + }); + }); + + console.log({ response }); + + const contentDisposition = response.headers["content-disposition"]; + let filename = ""; + if (contentDisposition) { + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + if (match) filename = match[1]; + } + + const blob = new Blob([response.data]); + const urlObj = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = urlObj; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(urlObj); + a.remove(); + }, +}; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..01cb76d --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,16 @@ +import * as schema from "./generated/schema"; +import { request } from "@/api"; +type CurrentUserPath = schema.paths["/api/users/current"]; +type CurrentUserResponse = + CurrentUserPath["get"]["responses"]["200"]["content"]["application/json"]; +export type User = CurrentUserResponse["user"]; + +export const getCurrentUser = async () => { + return await request.getUnauthenticated( + "/api/users/current" + ); +}; + +export const logoutUser = async () => { + return await request.delete("/api/users/current"); +}; diff --git a/frontend/src/assets/logo.png b/frontend/src/assets/logo.png new file mode 100644 index 0000000..0473bc9 Binary files /dev/null and b/frontend/src/assets/logo.png differ diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..a718b51 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,65 @@ +import { + Navbar, + Nav, + NavbarBrand, + NavItem, + NavLink, + Button, + UncontrolledDropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, +} from "reactstrap"; +import { logoutUser } from "@/api"; +import assetUrl from "@/assets/logo.png"; +import { useRouter } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { useUser } from "@/hooks"; + +export const NavbarComponent: React.FC = () => { + const imgUrl = new URL(assetUrl, import.meta.url).href; + const router = useRouter(); + const onLogout = useCallback(() => { + logoutUser().then(() => router.invalidate()); + }, [router]); + const user = useUser(); + + return ( + + + + + ); +}; diff --git a/frontend/src/components/ProtectedWrapper.tsx b/frontend/src/components/ProtectedWrapper.tsx new file mode 100644 index 0000000..34fa0e1 --- /dev/null +++ b/frontend/src/components/ProtectedWrapper.tsx @@ -0,0 +1,20 @@ +import { useRoles } from "@/hooks"; +import { every } from "lodash"; + +type ProtectedWrapperProps = { + requiredRoles: string[]; + children: React.ReactNode; +}; + +export const ProtectedWrapper: React.FC = ({ + requiredRoles, + children, +}) => { + const roles = useRoles(); + + if (!every(requiredRoles, (r) => roles.includes(r))) { + return <>; + } + + return <>{children}; +}; diff --git a/frontend/src/components/SidePanel/SidePanel.tsx b/frontend/src/components/SidePanel/SidePanel.tsx new file mode 100644 index 0000000..eefb2ad --- /dev/null +++ b/frontend/src/components/SidePanel/SidePanel.tsx @@ -0,0 +1,54 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { usePortal } from "@/hooks"; + +import { Button, Container } from "reactstrap"; + +export type SidePanelProps = { + children: React.ReactNode; + isVisible?: boolean; + onClose: () => void; + onOpen?: () => void; + style?: { [x: string]: string }; + title: React.ReactNode; +}; + +export const SidePanel: React.FC = ({ + children, + isVisible, + onClose, + style = {}, + title, +}) => { + const { portalRoot, createPortal } = usePortal(); + + return createPortal( + + {isVisible && ( + + +

{title}

+
+
+ + + {children} + + + +
+ )} +
, + portalRoot + ); +}; diff --git a/frontend/src/components/SidePanel/index.ts b/frontend/src/components/SidePanel/index.ts new file mode 100644 index 0000000..01efecd --- /dev/null +++ b/frontend/src/components/SidePanel/index.ts @@ -0,0 +1 @@ +export * from "./SidePanel"; diff --git a/frontend/src/components/SidePanel/utils.ts b/frontend/src/components/SidePanel/utils.ts new file mode 100644 index 0000000..c6408d3 --- /dev/null +++ b/frontend/src/components/SidePanel/utils.ts @@ -0,0 +1 @@ +export const SIDEPANEL_ANIMATION_TIME = 400 as const; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..4af75ea --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,3 @@ +export * from "./Navbar"; +export * from "./ProtectedWrapper"; +export * from "./SidePanel"; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 0000000..a70c8a9 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useUser"; +export * from "./usePortal"; diff --git a/frontend/src/hooks/usePortal.ts b/frontend/src/hooks/usePortal.ts new file mode 100644 index 0000000..3ecad8a --- /dev/null +++ b/frontend/src/hooks/usePortal.ts @@ -0,0 +1,24 @@ +import { useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; + +export const usePortal = () => { + const ref = useRef(document.createElement("div")); + const node = ref.current; + + useEffect(() => { + if (!node) return; + + node.id = "portal"; + document.body.appendChild(node); + + return () => { + if (!node) return; + document.body.removeChild(node); + }; + }, [node]); + + return { + portalRoot: node, + createPortal, + }; +}; diff --git a/frontend/src/hooks/useUser.ts b/frontend/src/hooks/useUser.ts new file mode 100644 index 0000000..a45116a --- /dev/null +++ b/frontend/src/hooks/useUser.ts @@ -0,0 +1,19 @@ +import { Route } from "@/routes/__root"; + +export const useRoles = () => { + const user = useUser(); + + if (!user?.groups) return []; + + return user.groups; +}; + +export const useUser = () => { + const data = Route.useLoaderData(); + + if (!data) return undefined; + + const { item } = data; + + return item; +}; diff --git a/frontend/src/main.scss b/frontend/src/main.scss new file mode 100644 index 0000000..f2db1bb --- /dev/null +++ b/frontend/src/main.scss @@ -0,0 +1,24 @@ +aside.sidepanel { + background-color: var(--bs-body-bg); + bottom: 0; + box-shadow: -20px 0 25px -5px rgba(0, 0, 0, 0.1), -10px 0 10px -5px rgba(0, 0, 0, 0.04); + padding: 24px 20px; + position: fixed; + overflow: auto; + right: 0; + top: 74px; + width: 100%; + z-index: 2; + + // Medium screens (768px+): 50% width + @media (min-width: 768px) { + padding: 36px 30px; + width: 50%; + } + + // Large screens (992px+): 33% width + @media (min-width: 992px) { + padding: 48px 40px; + width: 33%; + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..35c8448 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,26 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import "./main.scss"; + +// Import the generated route tree +import { routeTree } from "./routeTree.gen"; + +// Create a new router instance +const router = createRouter({ routeTree }); + +export const API_URL = "/api" as const; + +// Register the router instance for type safety +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..a34af05 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,59 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from "./routes/__root" +import { Route as IndexRouteImport } from "./routes/index" + +const IndexRoute = IndexRouteImport.update({ + id: "/", + path: "/", + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + "/": typeof IndexRoute +} +export interface FileRoutesByTo { + "/": typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + "/": typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: "/" + fileRoutesByTo: FileRoutesByTo + to: "/" + id: "__root__" | "/" + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { + id: "/" + path: "/" + fullPath: "/" + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..732af8d --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { NavbarComponent } from "@/components"; +import { getCurrentUser } from "@/api"; + +export const Route = createRootRoute({ + component: () => { + return ( + <> + + + + + ); + }, + loader: getCurrentUser, + errorComponent: () => ( + <> + +
Login to continue
+ + + ), +}); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx new file mode 100644 index 0000000..93e3657 --- /dev/null +++ b/frontend/src/routes/index.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { processFile } from "@/api/process"; + +const twoMonthsAgo = () => { + const now = new Date(); + const target = new Date(now.getFullYear(), now.getMonth() - 2, 1); + const end = new Date(target.getFullYear(), target.getMonth() + 1, 0); + const month = target.toLocaleString("en-US", { month: "long" }); + return `${month} 1 - ${month} ${end.getDate()}`; +}; + +const Index = () => { + const [file, setFile] = useState(null); + const [status, setStatus] = useState< + "idle" | "uploading" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const [employees, setEmployees] = useState([]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return; + + setStatus("uploading"); + setErrorMessage(""); + + try { + const result = await processFile(file); + setEmployees(result.employees); + setStatus("success"); + setFile(null); + } catch (err: unknown) { + setStatus("error"); + setErrorMessage( + err instanceof Error ? err.message : "Failed to process file", + ); + } + }; + + return ( +
+

Process Negative Points

+
+
+ + { + setFile(e.target.files?.[0] ?? null); + setStatus("idle"); + }} + /> +
+ This file must come from ESO. To get it, go to{" "} + + ESO Scheduler > Employees > Employee Reports > Employee + Hours Worked By Date Span + + .
Select the following date range:{" "} + {twoMonthsAgo()} +
+
+ +
+ {status === "success" && ( +
+ File processed successfully. + {employees.length > 0 && ( + <> +

Employees processed ({employees.length}):

+
    + {employees.map((name) => ( +
  • {name}
  • + ))} +
+ + )} +
+ )} + {status === "error" && ( +
{errorMessage}
+ )} +
+ ); +}; + +export const Route = createFileRoute("/")({ + component: Index, +}); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f2b0eb5 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "target": "ES2022", + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Imports */ + "paths": { + "@/*": ["./src/*"], + "@/test/*": ["./test/*"] + } + }, + "include": ["./src", "./test"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0dfc44e --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; +import image from "@rollup/plugin-image"; +import path from "node:path"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + TanStackRouterVite({ + autoCodeSplitting: true, + quoteStyle: "double", + target: "react", + }), + image(), + react(), + ], + resolve: { + alias: { + "@/test": path.resolve(__dirname, "./test"), + "@": path.resolve(__dirname, "./src"), + }, + }, + build: { + manifest: true, + sourcemap: true, + rollupOptions: { + input: { + reactApp: "./src/main.tsx", + }, + }, + outDir: "../public", + }, +}); diff --git a/go.mod b/go.mod index 248b742..6f5487a 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,72 @@ module clintonambulance.com/calculate_negative_points -go 1.25.5 +go 1.24.0 -require github.com/samber/lo v1.52.0 +require ( + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/dsa0x/sicher v0.2.4 + github.com/go-chi/chi/v5 v5.2.4 + github.com/go-chi/cors v1.2.2 + github.com/go-playground/validator/v10 v10.30.1 + github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/goforj/godump v1.9.0 + github.com/gorilla/sessions v1.4.0 + github.com/joho/godotenv v1.5.1 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/confmap v1.0.0 + github.com/knadh/koanf/providers/env v1.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/providers/posflag v1.0.1 + github.com/knadh/koanf/providers/structs v1.0.0 + github.com/knadh/koanf/v2 v2.3.2 + github.com/mitchellh/mapstructure v1.5.0 + github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/gomega v1.39.1 + github.com/samber/lo v1.39.0 + github.com/spf13/pflag v1.0.6 + github.com/swaggest/openapi-go v0.2.59 + github.com/swaggest/rest v0.2.75 + github.com/swaggest/usecase v1.3.1 + go.uber.org/zap v1.27.1 + golang.org/x/oauth2 v0.34.0 + golang.org/x/text v0.33.0 + resty.dev/v3 v3.0.0-beta.6 +) -require golang.org/x/text v0.22.0 // indirect +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/structs v1.1.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect + github.com/swaggest/form/v5 v5.1.1 // indirect + github.com/swaggest/jsonschema-go v0.3.78 // indirect + github.com/swaggest/refl v1.4.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.7 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum index f1fa120..7a95d69 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,188 @@ -github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= -github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.40 h1:LUSD+Aq+WB3KwVntqXstevJ0wB12ig1bEgoG8ZafsZU= +github.com/bool64/dev v0.2.40/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= +github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dsa0x/sicher v0.2.4 h1:XIOsyylc3lQ5Wf7PPIq5R80RggX7b7d0KNqHXvHqOhc= +github.com/dsa0x/sicher v0.2.4/go.mod h1:3+m3tC4maosJU24v/yJhXYqQxMbScCYIccryM9P9dGA= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goforj/godump v1.9.0 h1:Y/APfWKQKnJetXgVJxDqD7vEpTGSgAwbKJGmj0UAteI= +github.com/goforj/godump v1.9.0/go.mod h1:/Vy+p50JtOkwsFN5dA1HQ7LS5gtPk3f61DaP4UR2o4s= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= +github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b h1:FQ7+9fxhyp82ks9vAuyPzG0/vVbWwMwLJ+P6yJI5FN8= +github.com/juju/fslock v0.0.0-20160525022230-4d5c94c67b4b/go.mod h1:HMcgvsgd0Fjj4XXDkbjdmlbI505rUPBs6WBMYg2pXks= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE= +github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A= +github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc= +github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y= +github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk= +github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ5KvfgMbio+23u4= +github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 h1:levPcBfnazlA1CyCMC3asL/QLZkq9pa8tQZOH513zQw= +github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNEa7ik0fokNa2k30BKJ/W7Y= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7o2xQ= +github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= +github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= +github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= +github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= +github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= +github.com/swaggest/openapi-go v0.2.59 h1:9cUlCrSxbWn/Qn78IxitrhB5kaev0hOROfTxwywYLC0= +github.com/swaggest/openapi-go v0.2.59/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= +github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= +github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= +github.com/swaggest/rest v0.2.75 h1:MW9zZ3d0kduJ2KdWnSYZIIrZJ1v3Kg+S7QZrDCZcXws= +github.com/swaggest/rest v0.2.75/go.mod h1:yw+PNgpNSdD6W46r60keVXdsBB+7SKt64i2qpeuBsq4= +github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w= +github.com/swaggest/usecase v1.3.1/go.mod h1:cae3lDd5VDmM36OQcOOOdAlEDg40TiQYIp99S9ejWqA= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU= +resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM= diff --git a/internal/api/middleware/add_request_id.go b/internal/api/middleware/add_request_id.go new file mode 100644 index 0000000..6b58b00 --- /dev/null +++ b/internal/api/middleware/add_request_id.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/chi/v5/middleware" +) + +func AddRequestIDHeaderMiddleware(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(middleware.RequestIDHeader, middleware.GetReqID(r.Context())) + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) +} diff --git a/internal/api/middleware/current_user.go b/internal/api/middleware/current_user.go new file mode 100644 index 0000000..2f1788c --- /dev/null +++ b/internal/api/middleware/current_user.go @@ -0,0 +1,118 @@ +package middleware + +import ( + "context" + "net/http" + + "clintonambulance.com/calculate_negative_points/internal/config" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type Claims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups"` +} + +func verifyTokenAndGetClaims(config *config.ApplicationConfig, ctx context.Context, token string) (*oidc.IDToken, Claims, error) { + idToken, err := config.OidcConfig.Verifier.Verify(ctx, token) + claims := Claims{Groups: []string{}} + + if err != nil { + return idToken, claims, err + } + + err = idToken.Claims(&claims) + + if err != nil { + return idToken, claims, err + } + + return idToken, claims, nil +} + +func serveWithTokenAndClaims(next http.Handler, r *http.Request, w http.ResponseWriter, claims Claims, token *oidc.IDToken) { + ctx := context.WithValue(r.Context(), "user", token) + ctx = context.WithValue(ctx, "claims", claims) + next.ServeHTTP(w, r.WithContext(ctx)) +} + +func CurrentUserMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) { + middleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := config.CookieStore.Get(r, config.SessionName) + rawIDToken, ok := session.Values["id_token"].(string) + + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // In test environment, bypass OIDC verification and use mock claims + if config.Environment == "test" || config.Environment == "testEnvironment" { + mockClaims := Claims{ + Sub: "test-user-id", + Name: "Test User", + Email: "test@example.com", + Groups: []string{"calculate-negative-points-users"}, + } + serveWithTokenAndClaims(next, r, w, mockClaims, nil) + return + } + + idToken, claims, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken) + + if err == nil { + serveWithTokenAndClaims(next, r, w, claims, idToken) + return + } + + refreshToken, ok := session.Values["refresh_token"].(string) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Attempt refresh + tokenSrc := config.OidcConfig.OAuth2.TokenSource(r.Context(), &oauth2.Token{ + RefreshToken: refreshToken, + }) + + newToken, err := tokenSrc.Token() + if err != nil { + http.Error(w, "failed to refresh token", http.StatusUnauthorized) + return + } + + // Save new tokens + newRawIDToken, ok := newToken.Extra("id_token").(string) + if !ok { + http.Error(w, "missing id_token", http.StatusUnauthorized) + return + } + + idToken, claims, err = verifyTokenAndGetClaims(config, r.Context(), newRawIDToken) + + if err != nil { + http.Error(w, "invalid refreshed token", http.StatusUnauthorized) + return + } + + session.Values["id_token"] = newRawIDToken + if newToken.RefreshToken != "" { + session.Values["refresh_token"] = newToken.RefreshToken + } + if scope, ok := newToken.Extra("scope").(string); ok { + session.Values["scope"] = scope + } + session.Save(r, w) + + serveWithTokenAndClaims(next, r, w, claims, idToken) + return + }) + } + + return middleware, nil +} diff --git a/internal/api/middleware/error.go b/internal/api/middleware/error.go new file mode 100644 index 0000000..3c8181f --- /dev/null +++ b/internal/api/middleware/error.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + // "github.com/getsentry/sentry-go" + "clintonambulance.com/calculate_negative_points/internal/utils" + "github.com/swaggest/rest" + "github.com/swaggest/rest/nethttp" +) + +func ErrorResponder(w http.ResponseWriter, error string, code int) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(utils.NewApplicationError(error, code)) +} + +type errorWithFields interface { + error + Fields() map[string]interface{} +} + +// ErrorHandler middleware centralizes error handling and translation of errors between different error types +func ErrorHandler(next http.Handler) http.Handler { + var h *nethttp.Handler + if nethttp.HandlerAs(next, &h) { + resp := func(ctx context.Context, err error) (int, interface{}) { + // Handle AWS errors + + // Application-specific error handling: if this error has a marker interface, serialize it as JSON + var customErr utils.CustomApplicationError + if errors.As(err, &customErr) { + return customErr.HTTPStatus(), customErr + } + + // More detailed validation error messages + var validationErr errorWithFields + if errors.As(err, &validationErr) { + detailedErrorMessages := make([]string, len(validationErr.Fields())) + i := 0 + for k, v := range validationErr.Fields() { + detailedErrorMessages[i] = fmt.Sprintf("%s: %v", k, v) + i++ + } + return http.StatusUnprocessableEntity, utils.ApplicationError{ + Message: fmt.Sprintf("Validation errors: %s", strings.Join(detailedErrorMessages, ", ")), + } + } + + code, er := rest.Err(err) + return code, utils.ApplicationError{ + Message: er.ErrorText, + } + } + h.MakeErrResp = resp + } + return next +} diff --git a/internal/api/middleware/jwt_middleware.go b/internal/api/middleware/jwt_middleware.go new file mode 100644 index 0000000..b39da28 --- /dev/null +++ b/internal/api/middleware/jwt_middleware.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "strings" + + "clintonambulance.com/calculate_negative_points/internal/config" +) + +type contextKey string + +const userContextKey = contextKey("user") + +func audMatch(aud interface{}, expected string) bool { + switch v := aud.(type) { + case string: + return v == expected + case []interface{}: + for _, val := range v { + if s, ok := val.(string); ok && s == expected { + return true + } + } + } + return false +} + +func JWTMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) { + middleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodDelete { + // Skip auth for safe methods like GET/HEAD + next.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + + // Check if the header exists and starts with "Bearer " + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "Unauthorized: Bearer token missing or invalid", http.StatusUnauthorized) + return + } + + // Extract the token by removing the "Bearer " prefix + //tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + //token, err := jwt.Parse(tokenStr, config.Jwt.KeySet.Keyfunc) + //if err != nil || !token.Valid { + // http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + // return + //} + + //claims, ok := token.Claims.(jwt.MapClaims) + //if !ok { + // http.Error(w, "Invalid token claims", http.StatusUnauthorized) + // return + //} + + // Check `iss` and `aud` + // if claims["iss"] != config.Jwt.Issuer { + // http.Error(w, "Invalid issuer", http.StatusUnauthorized) + // return + // } + // audClaim := claims["aud"] + // if !audMatch(audClaim, config.Jwt.Audience) { + // http.Error(w, "Invalid audience", http.StatusUnauthorized) + // return + // } + + //ctx := context.WithValue(r.Context(), userContextKey, claims) + next.ServeHTTP(w, r.WithContext(r.Context())) + }) + } + + return middleware, nil +} diff --git a/internal/api/middleware/logging.go b/internal/api/middleware/logging.go new file mode 100644 index 0000000..1c000a4 --- /dev/null +++ b/internal/api/middleware/logging.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5/middleware" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type ContextKey string + +const ( + LoggerContext = ContextKey("logger") +) + +func LoggingMiddleware(logger *zap.Logger) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + start := time.Now() + defer func() { + if ww.Status() < 300 && strings.HasPrefix(r.URL.Path, "/health") { + // Don't log health checks unless they fail (ping endpoint returns empty response, HTTP status 204). + return + } + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + fields := []zapcore.Field{ + zap.Any("body", r.Body), + zap.String("remote_ip", r.RemoteAddr), + zap.String("method", r.Method), + zap.String("uri", r.URL.Path), + zap.String("request_id", middleware.GetReqID(r.Context())), + zap.Int("status", ww.Status()), + zap.Float64("latency_ms", float64(time.Since(start))/float64(time.Millisecond)), + zap.Int("size", ww.BytesWritten()), + } + logger.Info("HTTP request processed", fields...) + }() + r = r.WithContext(context.WithValue(r.Context(), LoggerContext, logger)) + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/internal/api/middleware/logout.go b/internal/api/middleware/logout.go new file mode 100644 index 0000000..7c674b5 --- /dev/null +++ b/internal/api/middleware/logout.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + + "clintonambulance.com/calculate_negative_points/internal/config" +) + +func Logout(config *config.ApplicationConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + session, _ := config.CookieStore.Get(r, config.SessionName) + session.Options.MaxAge = -1 + + err := session.Save(r, w) + + if err != nil { + http.Error(w, "Failed to delete session", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + return + } + + return http.HandlerFunc(fn) + } +} diff --git a/internal/api/middleware/oidc.go b/internal/api/middleware/oidc.go new file mode 100644 index 0000000..7151bac --- /dev/null +++ b/internal/api/middleware/oidc.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "context" + "net/http" + + "clintonambulance.com/calculate_negative_points/internal/config" +) + +func OidcMiddleware(config *config.ApplicationConfig) (func(http.Handler) http.Handler, error) { + middleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := config.CookieStore.Get(r, config.SessionName) + rawIDToken, ok := session.Values["id_token"].(string) + if !ok { + // Not authenticated; redirect to login + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + + idToken, _, err := verifyTokenAndGetClaims(config, r.Context(), rawIDToken) + if err != nil { + session.Options.MaxAge = -1 + session.Save(r, w) + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + + // Add token to context + ctx := context.WithValue(r.Context(), "id_token", idToken) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } + + return middleware, nil +} diff --git a/internal/api/middleware/pagination.go b/internal/api/middleware/pagination.go new file mode 100644 index 0000000..454d112 --- /dev/null +++ b/internal/api/middleware/pagination.go @@ -0,0 +1,48 @@ +package middleware + +import ( + "context" + "net/http" + "strconv" +) + +type Pagination struct { + Page int + PageSize int +} + +const PaginationContextKey = "pagination" + +func PaginationMiddleware() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + page := 1 + pageSize := 20 + + if p := r.URL.Query().Get("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed >= 1 { + page = parsed + } + } + + if ps := r.URL.Query().Get("page_size"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed >= 1 { + pageSize = parsed + } + } + + if pageSize > 100 { + pageSize = 100 + } + + ctx := context.WithValue(r.Context(), PaginationContextKey, Pagination{ + Page: page, + PageSize: pageSize, + }) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + + return http.HandlerFunc(fn) + } +} diff --git a/internal/api/requests/module.go b/internal/api/requests/module.go new file mode 100644 index 0000000..25a48ad --- /dev/null +++ b/internal/api/requests/module.go @@ -0,0 +1,36 @@ +package views_api + +import ( + "log" + "net/http" + + "clintonambulance.com/calculate_negative_points/internal/api/middleware" + "clintonambulance.com/calculate_negative_points/internal/api/requests/negative_points_processor" + "clintonambulance.com/calculate_negative_points/internal/api/requests/users" + "clintonambulance.com/calculate_negative_points/internal/config" + "github.com/go-chi/chi/v5" + "github.com/go-chi/cors" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/web" + "go.uber.org/zap" +) + +func MountInternalApiEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) { + e.Route("/api", func(r chi.Router) { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: true, + })) + currentUserMiddlware, err := middleware.CurrentUserMiddleware(config) + if err != nil { + log.Fatal(err) + } + r.Use(currentUserMiddlware) + + r.Method(http.MethodGet, "/users/current", nethttp.NewHandler(users.GetCurrentUser())) + r.Method(http.MethodPost, "/process", nethttp.NewHandler(negative_points_processor.NegativePointsProcessor(config, logger))) + r.With(middleware.Logout(config)).Method(http.MethodDelete, "/users/current", nethttp.NewHandler(users.Logout())) + }) +} diff --git a/internal/api/requests/negative_points_processor/process_negative_points.go b/internal/api/requests/negative_points_processor/process_negative_points.go new file mode 100644 index 0000000..63b312b --- /dev/null +++ b/internal/api/requests/negative_points_processor/process_negative_points.go @@ -0,0 +1,139 @@ +package negative_points_processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strings" + + "clintonambulance.com/calculate_negative_points/internal/api/middleware" + "clintonambulance.com/calculate_negative_points/internal/config" + "clintonambulance.com/calculate_negative_points/internal/nocodb" + "clintonambulance.com/calculate_negative_points/internal/utils" + "github.com/samber/lo" + "github.com/swaggest/usecase" + "go.uber.org/zap" +) + +type negativePointsProcessorInput struct { + File *multipart.FileHeader `formData:"file" description:"XLS schedule file to process"` +} + +type negativePointsProcessorOutput struct { + Employees []string `json:"employees" description:"List of employees who had negative points" nullable:"false" required:"true"` +} + +func hasMatch(record nocodb.NocoDBRecord, candidates []string, threshold int) bool { + normalizedName := utils.NormalizeName(utils.ToTitleCase(record.Name)) + bestDistance := threshold + 1 + + for _, candidate := range candidates { + normalizedCandidate := utils.NormalizeName(candidate) + distance := utils.LevenshteinDistance(normalizedName, normalizedCandidate) + + if distance < bestDistance { + bestDistance = distance + } + } + + if bestDistance <= threshold { + return true + } + return false +} + +func NegativePointsProcessor(config *config.ApplicationConfig, logger *zap.Logger) usecase.Interactor { + u := usecase.NewInteractor(func(ctx context.Context, input negativePointsProcessorInput, output *negativePointsProcessorOutput) error { + ctxUser := ctx.Value("claims").(middleware.Claims) + + file, err := input.File.Open() + if err != nil { + return err + } + defer file.Close() + + employees, err := utils.ParseUploadedXLSFile(input.File) + + if err != nil { + return err + } + + records, err := nocodb.Fetch(config) + if err != nil { + return err + } + + employees = lo.Filter(employees, func(e utils.Employee, _ int) bool { return e.Worked() }) + + names := lo.Map(employees, func(e utils.Employee, _ int) string { return e.Name }) + + // Convert XLS names to same format as API names (FirstName LastName) and normalize casing + xlsNamesConverted := lo.Map(names, func(name string, _ int) string { + return utils.ToTitleCase(utils.ConvertNameFormat(name)) + }) + + overlaps := lo.Filter(records, func(r nocodb.NocoDBRecord, _ int) bool { + return hasMatch(r, xlsNamesConverted, config.MatchThreshold) + }) + + currentNocodbUser, found := lo.Find(records, func(r nocodb.NocoDBRecord) bool { return hasMatch(r, []string{ctxUser.Name}, config.MatchThreshold) }) + + if !found { + return errors.New("Unable to match API user to NocoDB user") + } + + requestObjects := lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) nocodb.NocoDBRequest { + return nocodb.NocoDBRequest{ + EmployeeId: r.ID, + ReportedBy: currentNocodbUser.ID, + InfractionId: config.NocoDBConfig.NegativeInfractionId, + Date: utils.FirstDayOfMonth(), + } + }) + + // Marshal request objects to JSON + jsonData, err := json.Marshal(requestObjects) + if err != nil { + return err + } + + // Create POST request + req, err := http.NewRequest("POST", nocodb.AddInfractionsUrl(config), strings.NewReader(string(jsonData))) + if err != nil { + return err + } + + // Add authorization header + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value())) + req.Header.Set("Content-Type", "application/json") + + // Make the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Request failed with status %d: %s\n", resp.StatusCode, string(body)) + os.Exit(1) + } + + output.Employees = lo.Map(overlaps, func(r nocodb.NocoDBRecord, _ int) string { return r.Name }) + + return nil + }) + + u.SetDescription("Process Negative Points") + u.SetTags("Negative Points Processor") + + return u +} diff --git a/internal/api/requests/users/current.go b/internal/api/requests/users/current.go new file mode 100644 index 0000000..339feeb --- /dev/null +++ b/internal/api/requests/users/current.go @@ -0,0 +1,31 @@ +package users + +import ( + "context" + "go/types" + + "clintonambulance.com/calculate_negative_points/internal/api/middleware" + internal_types "clintonambulance.com/calculate_negative_points/internal/types" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/samber/lo" + "github.com/swaggest/usecase" +) + +func GetCurrentUser() usecase.Interactor { + u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *internal_types.UiUserResponse) error { + ctxId := ctx.Value("user").(*oidc.IDToken) + ctxUser := ctx.Value("claims").(middleware.Claims) + + if lo.IsNotEmpty(ctxId.Issuer) { + output.Item = internal_types.UiUser{ + Name: ctxUser.Name, + } + } + + return nil + }) + + u.SetDescription("Retrieve the current user") + u.SetTags("Users") + return u +} diff --git a/internal/api/requests/users/logout.go b/internal/api/requests/users/logout.go new file mode 100644 index 0000000..aade8b8 --- /dev/null +++ b/internal/api/requests/users/logout.go @@ -0,0 +1,17 @@ +package users + +import ( + "context" + "go/types" + + "github.com/swaggest/usecase" +) + +func Logout() usecase.Interactor { + u := usecase.NewInteractor(func(ctx context.Context, input types.Nil, output *map[string]interface{}) error { + return nil + }) + u.SetDescription("Logout current user") + u.SetTags("Users") + return u +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..39736b6 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,335 @@ +package config + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "reflect" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/dsa0x/sicher" + "github.com/go-playground/validator/v10" + "github.com/go-viper/mapstructure/v2" + "github.com/gorilla/sessions" + "github.com/joho/godotenv" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/providers/structs" + "github.com/knadh/koanf/v2" + "github.com/samber/lo" + "github.com/spf13/pflag" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +const defaultEnvironment = "development" + +type SicherObject interface { + SetEnvStyle(string) + LoadEnv(string, interface{}) error +} + +type SicherConfig struct { + AppSecret string `env:"APP_SECRET" koanf:"app_secret"` + AppSecretBlock string `env:"APP_SECRET_BLOCK" koanf:"app_secret_block"` + NocoDBToken string `env:"NOCODB_TOKEN" koanf:"nocodb.api_token"` + OidcClientId string `env:"OIDC_CLIENT_ID" koanf:"oidc.client_id"` + OidcClientSecret string `env:"OIDC_CLIENT_SECRET" koanf:"oidc.client_secret"` + ServiceAccountId string `env:"IDMS_SERVICE_ACCOUNT_ID" koanf:"idms.service_account_id"` + ServiceAccountPassword string `env:"IDMS_SERVICE_ACCOUNT_PASSWORD" koanf:"idms.service_account_password"` +} + +var NewSicherObject = func(environment string, path string) SicherObject { + return sicher.New(environment, path) +} + +type OidcConfig struct { + ClientId string `koanf:"oidc.client_id" validate:"required"` + ClientSecret Secret[string] `koanf:"oidc.client_secret" validate:"required"` + Issuer string `koanf:"oidc.issuer" validate:"required"` + Scopes []string `koanf:"oidc.scopes" validate:"required"` + Provider *oidc.Provider + Verifier *oidc.IDTokenVerifier + OAuth2 *oauth2.Config +} + +type IdmsConfig struct { + BaseUrl string `koanf:"idms.base_url" validate:"required"` + CarsGroupId string `koanf:"idms.cars_group_id" validate:"required"` + Id Secret[string] `koanf:"idms.service_account_id" validate:"required"` + Password Secret[string] `koanf:"idms.service_account_password" validate:"required"` +} + +type NocoDBConfig struct { + ApiToken Secret[string] `koanf:"nocodb.api_token" validate:"required"` + BaseUrl string `koanf:"nocodb.base_url" validate:"required"` + EmployeesTableId string `koanf:"nocodb.employees_table_id" validate:"required"` + InfractionsTableId string `koanf:"nocodb.infractions_table_id" validate:"required"` + NegativeInfractionId int `koanf:"nocodb.negative_infraction_id" validate:"required"` + NoPointsViewId string `koanf:"nocodb.no_points_view_id" validate:"required"` +} + +type ApplicationConfig struct { + AppSecret Secret[string] `koanf:"app_secret"` + AppSecretBlock Secret[string] `koanf:"app_secret_block"` + CookieStore *sessions.CookieStore + Environment string `koanf:"environment" validate:"required"` + Listen string `koanf:"listen" validate:"required,hostname_port"` + MatchThreshold int `koanf:"match_threshold" validate:"required"` + NocoDBConfig NocoDBConfig `koanf:"nocodb" validate:"required"` + OidcConfig OidcConfig `koanf:"oidc" validate:"required"` + PublicPath string `koanf:"paths.public"` + Idms IdmsConfig `koanf:"idms" validate:"required"` + SessionName string + ViewPath string `koanf:"paths.views"` +} + +type ParsedCliArgs struct { + ConfigFiles *[]string + DatabasePasswordPath *string + Environment *string + Listen *string + Version *bool +} + +func ParseArgs(version Version, exit func(int), args []string) (*ParsedCliArgs, *pflag.FlagSet) { + f := pflag.NewFlagSet("config", pflag.ContinueOnError) + f.Usage = func() { + fmt.Println(version.AppNameAndVersion(false)) + fmt.Println(f.FlagUsages()) + exit(0) + } + + parsedCliArgs := &ParsedCliArgs{ + ConfigFiles: f.StringSliceP("conf", "c", []string{}, "path to one or more .yaml config files"), + DatabasePasswordPath: f.StringP("db-secret-path", "d", "", "path to database secret"), + Environment: f.StringP("environment", "e", defaultEnvironment, "Environment for the running binary"), + Listen: f.StringP("listen", "l", "0.0.0.0:3000", "address and port to listen on"), + Version: f.Bool("version", false, "Show version"), + } + + err := f.Parse(args[1:]) + + if err != nil { + _, _ = fmt.Fprintf( + os.Stderr, + "%s\n\nERROR: %s\n\n%s", + version.AppNameAndVersion(false), err, f.FlagUsages(), + ) + exit(1) + } + + return parsedCliArgs, f +} + +// Load application configuration from config files, command line flags, and environment variables. +// An error will be returned only if _unexpected_ error happened, such as a file not being found. +// If the user specified a --help or a --version flag, the application will exit with a 0 status code. +// If the user specified an unsupported flag, the application will exit with a 1 status code and print the help message. +func Load(version Version, exit func(int), args []string, logger *zap.Logger) (*ApplicationConfig, error) { + k := koanf.New(".") + config := defaultConfig() + + err := k.Load(confmap.Provider(config, "."), nil) + + if err != nil { + log.Fatal("error initializing config", zap.Error(err)) + } + + parsedCliArgs, f := ParseArgs(version, exit, args) + + // Load the config files provided in the command line. + for _, c := range *parsedCliArgs.ConfigFiles { + err := k.Load(file.Provider(c), yaml.Parser()) + + if err != nil { + return nil, fmt.Errorf("error loading configuration file: %v", err) + } + } + + err = godotenv.Load() + if err != nil { + logger.Info("No .env file found") + } + + configPath := func(p string) string { + combinedPath := fmt.Sprintf("%s/%s", k.String("path_prefix"), p) + return combinedPath + } + + // Override with environment variables + lo.ForEach([]string{envPrefix, pgPrefix, databasePrefix, idmsPrefix, oidcPrefix}, func(prefix string, _ int) { + if err := k.Load(env.Provider(prefix, "_", mapEnvVarNames(prefix)), nil); err != nil { + logger.Fatal("error parsing environment variables", zap.Error(err)) + } + }) + + // Override with command-line values + if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil { + return nil, fmt.Errorf("error parsing command-line parameters: %v", err) + } + + if err := k.Unmarshal("", &config); err != nil { + return nil, err + } + + if *parsedCliArgs.Version { + fmt.Println(version.AppNameAndVersion(true)) + exit(0) + } + + var sicherConfig SicherConfig + + s := NewSicherObject(k.String("environment"), configPath(k.String("paths.credentials"))) + s.SetEnvStyle("yaml") // default is dotenv + err = s.LoadEnv("", &sicherConfig) + + cookieStore := sessions.NewCookieStore([]byte(k.String("app_secret"))) + cookieStore.Options.HttpOnly = true + cookieStore.Options.Secure = *parsedCliArgs.Environment != "dev" && *parsedCliArgs.Environment != "testEnvironment" // Use HTTPS + cookieStore.Options.SameSite = http.SameSiteLaxMode + + if err != nil { + log.Println(err) + } + + if err = k.Load(structs.Provider(sicherConfig, "koanf"), nil); err != nil { + return nil, fmt.Errorf("error parsing sicher parameters: %v", err) + } + + var appConfig ApplicationConfig + + unmarshalConf := koanf.UnmarshalConf{ + DecoderConfig: &mapstructure.DecoderConfig{ + DecodeHook: mapstructure.ComposeDecodeHookFunc( + SecretFilePathUnmarshalHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc(), + ), + Metadata: nil, + Result: &appConfig, + WeaklyTypedInput: true, + }, + } + + if err := k.UnmarshalWithConf("", &config, unmarshalConf); err != nil { + return nil, err + } + + appConfig.AppSecret = SecretFromValue(k.String("app_secret")) + appConfig.AppSecretBlock = SecretFromValue(k.String("app_secret_block")) + + nocodbConfig := NocoDBConfig{ + ApiToken: SecretFromValue(k.String("nocodb.api_token")), + BaseUrl: k.String("nocodb.base_url"), + EmployeesTableId: k.String("nocodb.employees_table_id"), + InfractionsTableId: k.String("nocodb.infractions_table_id"), + NegativeInfractionId: k.Int("nocodb.negative_infraction_id"), + NoPointsViewId: k.String("nocodb.no_points_view_id"), + } + + oidcConfig := OidcConfig{ + ClientId: k.String("oidc.client_id"), + ClientSecret: SecretFromValue(k.String("oidc.client_secret")), + Issuer: k.String("oidc.issuer"), + Scopes: k.Strings("oidc.scopes"), + } + + if *parsedCliArgs.Environment != "testEnvironment" { + authProvider, err := oidc.NewProvider(context.Background(), k.String("oidc.issuer")) + if err != nil { + return nil, err + } + oidcConfig.Provider = authProvider + oidcConfig.Verifier = authProvider.Verifier(&oidc.Config{ClientID: k.String("oidc.client_id")}) + redirectUrl := fmt.Sprintf("%s/auth/callback", k.String("oidc.redirect_url")) + oidcConfig.OAuth2 = &oauth2.Config{ + ClientID: k.String("oidc.client_id"), + ClientSecret: k.String("oidc.client_secret"), + Endpoint: authProvider.Endpoint(), + RedirectURL: redirectUrl, + Scopes: k.Strings("oidc.scopes"), + } + } + + appConfig.CookieStore = cookieStore + appConfig.Environment = *parsedCliArgs.Environment + appConfig.Idms = IdmsConfig{ + BaseUrl: k.String("idms.base_url"), + CarsGroupId: k.String("idms.cars_group_id"), + Id: SecretFromValue(k.String("idms.service_account_id")), + Password: SecretFromValue(k.String("idms.service_account_password")), + } + appConfig.Listen = *parsedCliArgs.Listen + appConfig.MatchThreshold = k.Int("match_threshold") + appConfig.NocoDBConfig = nocodbConfig + appConfig.OidcConfig = oidcConfig + appConfig.PublicPath = configPath(k.String("paths.public")) + appConfig.SessionName = "calculate-negative-points" + appConfig.ViewPath = configPath(k.String("paths.views")) + + validate := validator.New() + validate.RegisterCustomTypeFunc(secretTypeTranslator[string], Secret[string]{}) + err = validate.Struct(appConfig) + + if err != nil { + log.Println(err) + fmt.Print("Could not create config") + exit(1) + return nil, err + } + + return &appConfig, nil +} + +const envPrefix = "APP_" +const pgPrefix = "PG_" +const databasePrefix = "DATABASE_" +const idmsPrefix = "IDMS_" +const oidcPrefix = "OIDC_" + +/* + * Rio passes in PG_HOST and PG_PORT so we need to replace the PG string with DATABASE and then not trim the prefix + */ +func mapEnvVarNames(prefix string) func(s string) string { + return func(s string) string { + if prefix == databasePrefix { + return strings.ToLower(s) + } + + replacedPg := strings.Replace(s, "PG", "DATABASE", -1) + + lower := strings.ToLower(strings.TrimPrefix(replacedPg, prefix)) + + return lower + } +} + +func defaultConfig() map[string]interface{} { + dbConfig := map[string]interface{}{ + "host": "localhost", + "port": 5432, + "user": "postgres", + "name": "calculate-negative-points", + } + + return map[string]interface{}{ + "database": dbConfig, + "environment": defaultEnvironment, + "listen": "127.0.0.1:3000", + } +} + +func secretTypeTranslator[T SecretValue](field reflect.Value) interface{} { + if secret, ok := field.Interface().(Secret[T]); ok { + return secret.Value() + } + return nil +} diff --git a/internal/config/config_suite_test.go b/internal/config/config_suite_test.go new file mode 100644 index 0000000..c6e29ba --- /dev/null +++ b/internal/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d270f9e --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,41 @@ +package config_test + +import ( + "log" + "os" + + "clintonambulance.com/calculate_negative_points/internal/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type FakeSicherObject struct{} + +func (_ FakeSicherObject) SetEnvStyle(_ string) {} +func (_ FakeSicherObject) LoadEnv(_ string, obj interface{}) error { + var err error + + return err +} + +var _ = Describe("ConfigTest", func() { + var version config.Version + var logger, _ = config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"}) + shouldNotExit := func(code int) { + // "Fatal" to mimic os.Exit + log.Default().Print("exit called with code", code) + } + + BeforeEach(func() { + config.NewSicherObject = func(_ string, _ string) config.SicherObject { + return FakeSicherObject{} + } + version = config.Version{Release: "1.0.0", Commit: "abcdef", Date: "2023-01-01"} + }) + + It("Errors if the config file is not found", func() { + argv := []string{"cmd", "-c", "config/no-such-file.yml"} + _, err := config.Load(version, shouldNotExit, argv, logger) + Expect(err.Error()).To(Equal("error loading configuration file: open config/no-such-file.yml: no such file or directory")) + }) +}) diff --git a/internal/config/logging.go b/internal/config/logging.go new file mode 100644 index 0000000..1b6d1d7 --- /dev/null +++ b/internal/config/logging.go @@ -0,0 +1,34 @@ +package config + +import ( + "io" + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func NewLogger(version Version, output io.Writer, args []string) (*zap.Logger, func()) { + parsedCliArgs, _ := ParseArgs(version, os.Exit, args) + syncs := []zapcore.WriteSyncer{zapcore.AddSync(output)} + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(encoderConfig), + zap.CombineWriteSyncers(syncs...), + zap.DebugLevel, + ) + logger := zap.New(core).WithOptions( + zap.AddStacktrace(zap.ErrorLevel), + zap.WithCaller(false), + ).With( + zap.String("version", version.Release), + zap.String("environment", *parsedCliArgs.Environment), + ) + finalizer := func() { + // This might fail if logging to console, but we don't care (https://github.com/uber-go/zap/issues/880) + _ = logger.Sync() + } + return logger, finalizer +} diff --git a/internal/config/secret.go b/internal/config/secret.go new file mode 100644 index 0000000..8702199 --- /dev/null +++ b/internal/config/secret.go @@ -0,0 +1,85 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + + "github.com/mitchellh/mapstructure" +) + +// SecretValue defines what types of secrets this secret container supports +type SecretValue interface { + ~string | ~[]string | ~map[string]string +} + +// SecretFilePath is the source to the file containing the secret. +type SecretFilePath string + +const SecretPlaceholder = "[SECRET]" + +// Secret encapsulates a secret value, which is loaded from a file, or directly from configuration / environment variable +type Secret[T SecretValue] struct { + value T + source SecretFilePath +} + +// MarshalJSON implements the json.Marshaler interface, used to hide the actual secret value in logs etc. +func (s Secret[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]string{"value": SecretPlaceholder, "source": string(s.source)}) +} + +// MarshalText implements the encoding.TextMarshaler interface, used to hide the actual secret value in logs etc. +func (s *Secret[T]) MarshalText() ([]byte, error) { + return []byte(SecretPlaceholder), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface, used to load the secret from config files and env. values +func (s *Secret[T]) UnmarshalText(text []byte) error { + var val T + switch any(val).(type) { + case string: + s.value = any(string(text)).(T) + return nil + default: + return json.Unmarshal(text, &s.value) + } +} + +func (s Secret[T]) Value() T { + return s.value +} + +func SecretFromSecretPath[T SecretValue](path SecretFilePath) (Secret[T], error) { + contentBytes, err := os.ReadFile(string(path)) + if err != nil { + return Secret[T]{}, err + } + secret := Secret[T]{source: path} + err = secret.UnmarshalText(bytes.TrimSpace(contentBytes)) + return secret, err +} + +func SecretFromValue[T SecretValue](value T) Secret[T] { + return Secret[T]{value: value} +} + +// SecretFilePathUnmarshalHookFunc is a mapstructure.DecodeHookFunc that will convert a SecretFilePath to a Secret +func SecretFilePathUnmarshalHookFunc() mapstructure.DecodeHookFuncType { + return func(from, to reflect.Type, data interface{}) (interface{}, error) { + if from != reflect.TypeOf(SecretFilePath("")) { + return data, nil + } + // Reflection does not work with generics as of 1.20, so we have to do this manually + if to == reflect.TypeOf(Secret[map[string]string]{}) { + return SecretFromSecretPath[map[string]string](data.(SecretFilePath)) + } else if to == reflect.TypeOf(Secret[[]string]{}) { + return SecretFromSecretPath[[]string](data.(SecretFilePath)) + } else if to == reflect.TypeOf(Secret[string]{}) { + return SecretFromSecretPath[string](data.(SecretFilePath)) + } else { + return data, nil + } + } +} diff --git a/internal/config/testdata/DATABASE_SECRET.sample b/internal/config/testdata/DATABASE_SECRET.sample new file mode 100644 index 0000000..b79bbef --- /dev/null +++ b/internal/config/testdata/DATABASE_SECRET.sample @@ -0,0 +1 @@ +fakesecret \ No newline at end of file diff --git a/internal/config/testdata/settings.test.yml b/internal/config/testdata/settings.test.yml new file mode 100644 index 0000000..b866a2c --- /dev/null +++ b/internal/config/testdata/settings.test.yml @@ -0,0 +1,22 @@ +app_secret: "app secret" +app_secret_block: "app secret block hash" +path_prefix: "." +paths: + migrations: "db/migrations" + credentials: "config/credentials" +match_threshold: 3 +nocodb: + api_token: test + base_url: https://example.com + employees_table_id: "1234567890" + infractions_table_id: "2468013579" + negative_infraction_id: 1 + no_points_view_id: "1357924680" +oidc: + issuer: http://example.com + redirect_url: http://example.com +idms: + base_url: https://example.com + cars_group_id: "1234" + id: "1234" + password: "5678" \ No newline at end of file diff --git a/internal/config/version.go b/internal/config/version.go new file mode 100644 index 0000000..b0f6f49 --- /dev/null +++ b/internal/config/version.go @@ -0,0 +1,34 @@ +package config + +import "fmt" + +type Version struct { + Release string + Commit string + Date string +} + +var ( + release = "dev" + commit = "" + date = "" +) + +func (v *Version) AppNameAndVersion(details bool) string { + result := fmt.Sprintf("Basin Feature Flag Service %s", v.Release) + if details && v.Commit != "" { + result = fmt.Sprintf("%s\nGit commit: %s", result, v.Commit) + } + if details && v.Date != "" { + result = fmt.Sprintf("%s\nBuilt at: %s", result, v.Date) + } + return result +} + +func NewVersion() Version { + return Version{ + Release: release, + Commit: commit, + Date: date, + } +} diff --git a/internal/idms/request.go b/internal/idms/request.go new file mode 100644 index 0000000..b79c171 --- /dev/null +++ b/internal/idms/request.go @@ -0,0 +1,103 @@ +package idms + +import ( + "fmt" + + "clintonambulance.com/calculate_negative_points/internal/config" + "resty.dev/v3" +) + +type Method string + +const Get Method = "GET" +const Post Method = "POST" +const Put Method = "PUT" +const Delete Method = "Delete" + +type ResponsePagination struct { + Count int `json:"count"` + Current int `json:"current"` + EndIndex int `json:"end_index"` + Next int `json:"next"` + Previous int `json:"previous"` + StartIndex int `json:"start_index"` + TotalPages int `json:"total_pages"` +} + +type IdmsResponse[T any] struct { + Pagination ResponsePagination `json:"pagination"` + Results []T `json:"results"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` +} + +type IdmsRequest[T any] struct { + Method Method + Path string + ResponseDecoder *IdmsResponse[T] +} + +func newRestyRequest(url string, path string) *resty.Request { + client := resty.New() + formattedUrl := fmt.Sprintf("%s%s", url, path) + req := client.R() + req.SetURL(formattedUrl) + defer client.Close() + + return req +} + +func GetToken(config *config.ApplicationConfig) (string, error) { + req := newRestyRequest(config.Idms.BaseUrl, "/application/o/token/") + + req.SetResult(&TokenResponse{}) + req.SetMethod(string(Post)) + req.SetFormData(map[string]string{ + "client_id": config.Idms.Id.Value(), + "grant_type": "client_credentials", + "password": config.Idms.Password.Value(), + "scope": "goauthentik.io/api", + "username": "service", + }) + + res, err := req.Send() + + if err != nil { + return "", err + } + + data := res.Result().(*TokenResponse).AccessToken + + return data, nil +} + +func NewRequest[T any](path string, method Method) IdmsRequest[T] { + return IdmsRequest[T]{ + Method: method, + Path: path, + ResponseDecoder: &IdmsResponse[T]{}, + } +} + +func (r IdmsRequest[T]) Perform(config *config.ApplicationConfig, bodyData interface{}, headers map[string]string) ([]T, error) { + req := newRestyRequest(config.Idms.BaseUrl, r.Path) + token, err := GetToken(config) + + if err != nil { + return []T{}, err + } + + req.SetResult(r.ResponseDecoder).SetAuthToken(token).SetContentType("application/json") + req.SetMethod(string(r.Method)) + res, err := req.Send() + + if err != nil { + return []T{}, err + } + + data := res.Result().(*IdmsResponse[T]).Results + + return data, nil +} diff --git a/internal/idms/users.go b/internal/idms/users.go new file mode 100644 index 0000000..75f39fa --- /dev/null +++ b/internal/idms/users.go @@ -0,0 +1,74 @@ +package idms + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "clintonambulance.com/calculate_negative_points/internal/config" + "github.com/samber/lo" +) + +// FlexibleString handles JSON values that can be either strings or numbers +type FlexibleString string + +func (f *FlexibleString) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err == nil { + *f = FlexibleString(s) + return nil + } + + var n json.Number + if err := json.Unmarshal(data, &n); err == nil { + *f = FlexibleString(n.String()) + return nil + } + + return fmt.Errorf("cannot unmarshal %s into FlexibleString", data) +} + +type IdmsUserAttributes struct { + Cars bool `json:"cars"` + EmployeeId FlexibleString `json:"employee_id"` +} + +type IdmsUser struct { + Attributes IdmsUserAttributes `json:"attributes"` + Groups []string `json:"groups"` + Id string `json:"uid"` + Name string `json:"name"` + Username string `json:"username"` +} + +func (u IdmsUser) FirstName() string { + parts := strings.Fields(u.Name) + if len(parts) == 0 { + return "" + } + return parts[0] +} + +func (u IdmsUser) LastName() string { + parts := strings.Fields(u.Name) + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +func GetCarsMembers(config *config.ApplicationConfig) []IdmsUser { + req := NewRequest[IdmsUser]("/api/v3/core/users/?is_active=true", Get) + results, _ := req.Perform(config, map[string]string{}, map[string]string{}) + + filtered := lo.Filter(results, func(u IdmsUser, _ int) bool { + return lo.Contains(u.Groups, config.Idms.CarsGroupId) + }) + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].LastName() < filtered[j].LastName() + }) + + return filtered +} diff --git a/internal/models/models_suite_test.go b/internal/models/models_suite_test.go new file mode 100644 index 0000000..4be06f1 --- /dev/null +++ b/internal/models/models_suite_test.go @@ -0,0 +1,13 @@ +package models_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestModels(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Models Suite") +} diff --git a/internal/nocodb/request.go b/internal/nocodb/request.go new file mode 100644 index 0000000..3c937de --- /dev/null +++ b/internal/nocodb/request.go @@ -0,0 +1,108 @@ +package nocodb + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "clintonambulance.com/calculate_negative_points/internal/config" +) + +type NocoDBRecord struct { + ID int `json:"ID"` + Name string `json:"Name"` +} + +// NocoDBResponse represents the API response structure +type NocoDBResponse struct { + List []NocoDBRecord `json:"list"` + PageInfo struct { + TotalRows int `json:"totalRows"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + IsFirstPage bool `json:"isFirstPage"` + IsLastPage bool `json:"isLastPage"` + } `json:"pageInfo"` +} + +type NocoDBRequest struct { + EmployeeId int `json:"Employees_id"` + ReportedBy int `json:"Employees_id1"` + InfractionId int `json:"Infractions_id"` + Date string `json:"Date"` +} + +func NoPointsUrl(config *config.ApplicationConfig) string { + combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.EmployeesTableId, "records") + return fmt.Sprintf("%s?viewId=%s", combinedUrl, config.NocoDBConfig.NoPointsViewId) +} + +func AddInfractionsUrl(config *config.ApplicationConfig) string { + combinedUrl, _ := url.JoinPath(config.NocoDBConfig.BaseUrl, "api/v2/tables", config.NocoDBConfig.InfractionsTableId, "records") + return combinedUrl +} + +func Fetch(config *config.ApplicationConfig) ([]NocoDBRecord, error) { + records := []NocoDBRecord{} + offset := 0 + limit := 25 + isLastPage := false + + baseURL := NoPointsUrl(config) + + for !isLastPage { + // Create HTTP request with base URL + req, err := http.NewRequest("GET", baseURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set query params via the request's URL + q := req.URL.Query() + q.Set("offset", fmt.Sprintf("%d", offset)) + q.Set("limit", fmt.Sprintf("%d", limit)) + req.URL.RawQuery = q.Encode() + + // Add authorization header + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.NocoDBConfig.ApiToken.Value())) + req.Header.Set("Content-Type", "application/json") + + // Make the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch data from API: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Read and parse response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var nocoDBResp NocoDBResponse + if err := json.Unmarshal(body, &nocoDBResp); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %w", err) + } + + // Extract names from records + records = append(records, nocoDBResp.List...) + + // Check if we've reached the last page + isLastPage = nocoDBResp.PageInfo.IsLastPage + + // Update offset for next page + offset += limit + } + + return records, nil +} diff --git a/internal/server/http.go b/internal/server/http.go new file mode 100644 index 0000000..59ecfe7 --- /dev/null +++ b/internal/server/http.go @@ -0,0 +1,64 @@ +package server + +import ( + "encoding/json" + "net/http" + + apimiddleware "clintonambulance.com/calculate_negative_points/internal/api/middleware" + views_api "clintonambulance.com/calculate_negative_points/internal/api/requests" + "clintonambulance.com/calculate_negative_points/internal/config" + internal_web "clintonambulance.com/calculate_negative_points/internal/web" + "github.com/go-chi/chi/v5/middleware" + "github.com/swaggest/openapi-go/openapi31" + "github.com/swaggest/rest/response" + "github.com/swaggest/rest/web" + "go.uber.org/zap" +) + +type ErrorResponse struct { + Message string `json:"message"` +} + +func errorResponder(w http.ResponseWriter, error string, code int) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(ErrorResponse{Message: error}) +} + +func notFoundResponder(w http.ResponseWriter, _r *http.Request) { + errorResponder(w, "Not Found", http.StatusNotFound) +} + +func NewHttpServer(logger *zap.Logger, version config.Version) (*web.Service, *openapi31.Reflector) { + reflector := openapi31.NewReflector() + service := web.NewService(reflector) + + service.OpenAPISchema().SetTitle("Calculate Negative Points") + service.OpenAPISchema().SetDescription("Easily maintain Calculate Negative Points") + service.OpenAPISchema().SetVersion("v1.0.0") + + service.Use( + middleware.RealIP, + middleware.RequestID, + apimiddleware.AddRequestIDHeaderMiddleware, + apimiddleware.LoggingMiddleware(logger), + apimiddleware.ErrorHandler, + ) + service.NotFound(notFoundResponder) + + return service, reflector +} + +func MountAllEndpoints(srv *web.Service, version config.Version, config *config.ApplicationConfig, logger *zap.Logger) { + views_api.MountInternalApiEndpoints(srv, config, logger) + + // This should be last because it does wildcard matching + internal_web.MountWebEndpoints(srv, config, logger) +} + +func init() { + // Override some global defaults: + // This is just "application/json" by default, let's include the charset as well. + response.DefaultSuccessResponseContentType = "application/json; charset=UTF-8" +} diff --git a/internal/server/http_test.go b/internal/server/http_test.go new file mode 100644 index 0000000..d02a5ce --- /dev/null +++ b/internal/server/http_test.go @@ -0,0 +1,51 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "testing" + + "clintonambulance.com/calculate_negative_points/internal/config" + "clintonambulance.com/calculate_negative_points/internal/server" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestServer(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Http Suite") +} + +var _ = Describe("NewHttpServer", func() { + It("Creates a new HttpServer", func() { + var logOutput bytes.Buffer + version := config.Version{Release: "test-version"} + logger, _ := config.NewLogger(version, &logOutput, []string{"cmd", "-e", "testEnvironment"}) + srv, _ := server.NewHttpServer(logger, version) + srv.Method(http.MethodHead, "/dummy-route", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/foobar", nil) + rec := httptest.NewRecorder() + + // Validate that the server responds with the web frontend for unknown routes + srv.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusNotFound)) + Expect(rec.Header().Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(rec.Body.String()).To(ContainSubstring(`{"message":"Not Found"}`)) + + // Validate that it writes to the log for all requests + var logMessage map[string]interface{} + err := json.Unmarshal(logOutput.Bytes(), &logMessage) + Expect(err).ToNot(HaveOccurred()) + Expect(logMessage["msg"]).To(Equal("HTTP request processed")) + Expect(logMessage["method"]).To(Equal("GET")) + Expect(logMessage["uri"]).To(Equal("/foobar")) + Expect(logMessage["status"]).To(Equal(404.0)) + }) +}) diff --git a/internal/test/http_helper.go b/internal/test/http_helper.go new file mode 100644 index 0000000..40f8374 --- /dev/null +++ b/internal/test/http_helper.go @@ -0,0 +1,130 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "os" + + "clintonambulance.com/calculate_negative_points/internal/config" + "clintonambulance.com/calculate_negative_points/internal/server" + "github.com/onsi/gomega/ghttp" + "github.com/swaggest/rest/web" +) + +var version = config.Version{Release: "test-version", Commit: "test-commit"} + +func CreateRequestBodyFromStruct(body interface{}) *bytes.Buffer { + return bytes.NewBuffer(CreateResponseBodyFromStruct(body)) +} + +func CreateResponseBodyFromStruct(body interface{}) []byte { + jsonValue, err := json.Marshal(body) + + if err != nil { + log.Fatal("Error marshaling struct") + } + + return jsonValue +} + +func URLFromServerAndPath(server *ghttp.Server, path string) string { + return fmt.Sprintf("%s%s", server.URL(), path) +} + +func CreateHttpServer(appConfig *config.ApplicationConfig) *web.Service { + logger, flushLogs := config.NewLogger(version, os.Stdout, []string{"cmd", "-e", "testEnvironment"}) + defer flushLogs() + service, _ := server.NewHttpServer(logger, version) + server.MountAllEndpoints(service, version, appConfig, logger) + + return service +} + +// TestClaims represents the mock user claims for testing +type TestClaims struct { + Sub string + Name string + Email string + Groups []string +} + +// DefaultTestClaims returns default test user claims +func DefaultTestClaims() TestClaims { + return TestClaims{ + Sub: "test-user-id", + Name: "Test User", + Email: "test@example.com", + Groups: []string{"calculate-negative-points-users"}, + } +} + +// addMockSessionCookie adds a mock session cookie to the request with the given claims +func addMockSessionCookie(req *http.Request, appConfig *config.ApplicationConfig) { + // Create a mock session with a fake id_token + session, _ := appConfig.CookieStore.New(req, appConfig.SessionName) + session.Values["id_token"] = "mock-test-token" + session.Values["refresh_token"] = "mock-refresh-token" + + // Encode the session to a cookie + rec := httptest.NewRecorder() + session.Save(req, rec) + + // Copy the Set-Cookie header to the request as a Cookie header + for _, cookie := range rec.Result().Cookies() { + req.AddCookie(cookie) + } +} + +func PerformHttpRequest(method string, path string, requestParams ...map[string]interface{}) *httptest.ResponseRecorder { + return PerformHttpRequestWithClaims(method, path, DefaultTestClaims(), requestParams...) +} + +func PerformHttpRequestWithClaims(method string, path string, claims TestClaims, requestParams ...map[string]interface{}) *httptest.ResponseRecorder { + var body map[string]interface{} + headers := map[string]string{} + query := map[string]string{} + appConfig := CreateTestConfig() + srv := CreateHttpServer(appConfig) + + for i, arg := range requestParams { + switch i { + case 0: + body = arg + case 1: + for header, value := range arg { + headers[header] = fmt.Sprintf("%v", value) + } + case 2: + for key, value := range arg { + query[key] = fmt.Sprintf("%v", value) + } + default: + panic("Unknown argument") + } + } + + req := httptest.NewRequest(method, path, CreateRequestBodyFromStruct(body)) + + q := req.URL.Query() + for key, value := range query { + q.Add(key, value) + } + req.URL.RawQuery = q.Encode() + + for header, value := range headers { + req.Header.Set(header, value) + } + + // Add mock session cookie for authentication + addMockSessionCookie(req, appConfig) + + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + return rec +} diff --git a/internal/test/test_helper.go b/internal/test/test_helper.go new file mode 100644 index 0000000..7aff0a8 --- /dev/null +++ b/internal/test/test_helper.go @@ -0,0 +1,53 @@ +package test + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + + "clintonambulance.com/calculate_negative_points/internal/config" + "github.com/gorilla/sessions" + "github.com/onsi/ginkgo/v2" +) + +func CreateTestConfig() *config.ApplicationConfig { + appSecret := "12345678901234567890123456789012" + appSecretBlock := "09876543210987654321098765431098" + + basepath := os.Getenv("APP_MIGRATION_DIRECTORY") + if basepath == "" { + _, b, _, _ := runtime.Caller(0) + basepath = filepath.Dir(b) + } + + return &config.ApplicationConfig{ + AppSecret: config.SecretFromValue(appSecret), + AppSecretBlock: config.SecretFromValue(appSecretBlock), + CookieStore: sessions.NewCookieStore([]byte(appSecret)), + Environment: "test", + Listen: "127.0.0.1:3000", + MatchThreshold: 3, + SessionName: "calculate-negative-points-test", + NocoDBConfig: config.NocoDBConfig{ + ApiToken: config.SecretFromValue("1234567890"), + BaseUrl: "https://example.com", + EmployeesTableId: "0987654321", + InfractionsTableId: "2468013579", + NegativeInfractionId: 1, + NoPointsViewId: "1357924680", + }, + } +} + +func UnmarshalBody[K comparable, V any](rec *httptest.ResponseRecorder) map[K]V { + var body map[K]V + err := json.NewDecoder(rec.Body).Decode(&body) + + if err != nil { + ginkgo.Fail("Could not decode response") + } + + return body +} diff --git a/internal/types/generic.go b/internal/types/generic.go new file mode 100644 index 0000000..90bcbae --- /dev/null +++ b/internal/types/generic.go @@ -0,0 +1,28 @@ +package types + +// PaginationRequest is a standard pagination input for API requests +type PaginationRequest struct { + Page int `json:"page" query:"page" default:"1" minimum:"1"` + PageSize int `json:"page_size" query:"page_size" default:"20" minimum:"1" maximum:"100"` +} + +// PaginationResponse is a standard pagination output for API responses +type PaginationResponse struct { + Page int `json:"page" required:"true"` + PageSize int `json:"page_size" required:"true"` + Total int `json:"total" required:"true"` + TotalPages int `json:"total_pages" required:"true"` +} + +type PaginatedIndexResponse[T any] struct { + Items []T `json:"items" required:"true" nullable:"false"` + Pagination PaginationResponse `json:"pagination" required:"true" nullable:"false"` +} + +type IndexResponse[T any] struct { + Items []T `json:"items" required:"true" nullable:"false"` +} + +type ShowResponse[T any] struct { + Item T `json:"item" required:"true" nullable:"false"` +} diff --git a/internal/types/payroll_categories.go b/internal/types/payroll_categories.go new file mode 100644 index 0000000..d74d528 --- /dev/null +++ b/internal/types/payroll_categories.go @@ -0,0 +1,9 @@ +package types + +type UiPayrollCategory struct { + Name string `json:"name" required:"true"` + Rate float64 `json:"rate" required:"true" format:"double"` + Id int `json:"id" required:"true"` +} + +type UiPayrollCategoriesResponse = IndexResponse[UiPayrollCategory] diff --git a/internal/types/payroll_entries.go b/internal/types/payroll_entries.go new file mode 100644 index 0000000..ba76f90 --- /dev/null +++ b/internal/types/payroll_entries.go @@ -0,0 +1,11 @@ +package types + +type UiPayrollEntry struct { + Category UiPayrollCategory `json:"category" required:"true" nullable:"false"` + Date string `json:"date" format:"iso8601"` + Description string `json:"description"` + Employee UiUser `json:"employee" required:"true" nullable:"false"` + Quantity float64 `json:"quantity" format:"double" required:"true"` +} + +type UiPayrollEntriesResponse = PaginatedIndexResponse[UiPayrollEntry] diff --git a/internal/types/users.go b/internal/types/users.go new file mode 100644 index 0000000..ca018d3 --- /dev/null +++ b/internal/types/users.go @@ -0,0 +1,24 @@ +package types + +import "clintonambulance.com/calculate_negative_points/internal/idms" + +type UiUser struct { + FirstName string `json:"first_name"` + Groups []string `json:"groups,omitempty" required:"false"` + Id string `json:"id" required:"true" nullable:"false"` + LastName string `json:"last_name"` + Name string `json:"name" required:"true" nullable:"false"` +} + +type UiUsersResponse = IndexResponse[UiUser] +type UiUserResponse = ShowResponse[UiUser] + +func UiUserFromIdmsUser(u idms.IdmsUser) UiUser { + return UiUser{ + FirstName: u.FirstName(), + Groups: u.Groups, + Id: string(u.Attributes.EmployeeId), + LastName: u.LastName(), + Name: u.Name, + } +} diff --git a/internal/utils/convert_name_format.go b/internal/utils/convert_name_format.go new file mode 100644 index 0000000..cdbe7d1 --- /dev/null +++ b/internal/utils/convert_name_format.go @@ -0,0 +1,13 @@ +package utils + +import "strings" + +func ConvertNameFormat(name string) string { + parts := strings.Split(name, ",") + if len(parts) == 2 { + lastName := strings.TrimSpace(parts[0]) + firstName := strings.TrimSpace(parts[1]) + return firstName + " " + lastName + } + return name +} diff --git a/internal/utils/error.go b/internal/utils/error.go new file mode 100644 index 0000000..182bd79 --- /dev/null +++ b/internal/utils/error.go @@ -0,0 +1,43 @@ +package utils + +import ( + "net/http" + + "github.com/swaggest/rest" +) + +// CustomApplicationError is a marker interface that tells error middleware to serialize this error as-is, using Json schema. +type CustomApplicationError interface { + rest.ErrWithHTTPStatus + CustomApplicationError() +} + +type ApplicationError struct { + Message string `json:"message"` + code int +} + +var ( + _ CustomApplicationError = ApplicationError{} +) + +func NewApplicationError(message string, code int) ApplicationError { + return ApplicationError{ + Message: message, + code: code, + } +} + +func (e ApplicationError) Error() string { + return e.Message +} + +func (e ApplicationError) HTTPStatus() int { + return e.code +} + +func (e ApplicationError) CustomApplicationError() {} + +func ValidationError(message string) ApplicationError { + return NewApplicationError(message, http.StatusUnprocessableEntity) +} diff --git a/internal/utils/first_day_of_month.go b/internal/utils/first_day_of_month.go new file mode 100644 index 0000000..db766a5 --- /dev/null +++ b/internal/utils/first_day_of_month.go @@ -0,0 +1,20 @@ +package utils + +import "time" + +func FirstDayOfMonth() string { + // Load America/Detroit timezone + loc, err := time.LoadLocation("America/Detroit") + if err != nil { + panic(err) + } + + // Get current time in Detroit timezone + now := time.Now().In(loc) + + // Create midnight of the first day of the current month + firstDay := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + + // Return ISO8601 formatted string + return firstDay.Format(time.RFC3339) +} diff --git a/internal/utils/levenshtein_distance.go b/internal/utils/levenshtein_distance.go new file mode 100644 index 0000000..9d12257 --- /dev/null +++ b/internal/utils/levenshtein_distance.go @@ -0,0 +1,34 @@ +package utils + +import "strings" + +func LevenshteinDistance(s1, s2 string) int { + s1 = strings.ToLower(s1) + s2 = strings.ToLower(s2) + + len1 := len(s1) + len2 := len(s2) + + matrix := make([][]int, len1+1) + for i := range matrix { + matrix[i] = make([]int, len2+1) + matrix[i][0] = i + } + for j := range matrix[0] { + matrix[0][j] = j + } + + for i := 1; i <= len1; i++ { + for j := 1; j <= len2; j++ { + cost := 0 + if s1[i-1] != s2[j-1] { + cost = 1 + } + matrix[i][j] = min( + matrix[i-1][j]+1, + min(matrix[i][j-1]+1, matrix[i-1][j-1]+cost), + ) + } + } + return matrix[len1][len2] +} diff --git a/internal/utils/logging.go b/internal/utils/logging.go new file mode 100644 index 0000000..1070271 --- /dev/null +++ b/internal/utils/logging.go @@ -0,0 +1,19 @@ +package utils + +import ( + "fmt" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func LogMessageFromDatabaseOperation(entityName string, operation string, dbOp func() error) (string, error) { + err := dbOp() + logMsg := fmt.Sprintf("%s %sd successfully", cases.Title(language.English).String(entityName), operation) + + if err != nil { + logMsg = fmt.Sprintf("%s %s encountered an error", cases.Title(language.English).String(operation), entityName) + } + + return logMsg, err +} diff --git a/internal/utils/must.go b/internal/utils/must.go new file mode 100644 index 0000000..fee7405 --- /dev/null +++ b/internal/utils/must.go @@ -0,0 +1,12 @@ +package utils + +import "go.uber.org/zap" + +func Must[T any](res T, err error) func(logger *zap.Logger) T { + return func(logger *zap.Logger) T { + if err != nil { + logger.Fatal("Fatal error", zap.Error(err)) + } + return res + } +} diff --git a/internal/utils/normalize_name.go b/internal/utils/normalize_name.go new file mode 100644 index 0000000..3ea1a7a --- /dev/null +++ b/internal/utils/normalize_name.go @@ -0,0 +1,23 @@ +package utils + +import ( + "regexp" + "strings" + "unicode" +) + +func NormalizeName(name string) string { + // Remove extra whitespace + name = strings.TrimSpace(name) + name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ") + + // Remove common punctuation but keep hyphens + name = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsSpace(r) || r == '-' { + return unicode.ToLower(r) + } + return -1 + }, name) + + return name +} diff --git a/internal/utils/parse_xls_file.go b/internal/utils/parse_xls_file.go new file mode 100644 index 0000000..02b2c71 --- /dev/null +++ b/internal/utils/parse_xls_file.go @@ -0,0 +1,105 @@ +package utils + +import ( + "fmt" + "io" + "mime/multipart" + "regexp" + "strconv" + "strings" + + "github.com/samber/lo" +) + +type Shift struct { + EarningCode string `json:"earning_code"` + Description string `json:"description"` + Hours float64 `json:"hours"` +} + +func (s Shift) CountsAsWorked() bool { + return strings.ToLower(s.EarningCode) == "reg" +} + +type Employee struct { + Name string `json:"name"` + Shifts []Shift `json:"shifts"` +} + +func (e Employee) Worked() bool { + return lo.ContainsBy(e.Shifts, func(s Shift) bool { return s.CountsAsWorked() }) +} + +func ParseUploadedXLSFile(file *multipart.FileHeader) ([]Employee, error) { + f, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed to open XLS file: %w", err) + } + defer f.Close() + + content, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("failed to read XLS file: %w", err) + } + + return ParseXLSContent(content) +} + +// ParseXLSContent parses the HTML content of an XLS file and returns a slice +// of employees with their shifts. The XLS files are HTML tables where: +// - Column A: employee name (td with class="smallbold" and valign=top) +// - Column B: inner table containing shift rows +// - Within the inner table, rows with colspan are category headers (skipped) +// - Shift rows have: spacer td, earning code/description td (class="smalltext"), +// and hours td +func ParseXLSContent(content []byte) ([]Employee, error) { + html := string(content) + + // Split by employee rows in the outer table. Each employee row contains + // a name cell (column A) followed by a cell with an inner table (column B). + employeePattern := regexp.MustCompile( + `([^<]+)\s*(.*?)`, + ) + employeeMatches := employeePattern.FindAllStringSubmatch(html, -1) + + // Pattern for shift data rows: spacer td + earning code td + hours td + // These are rows where column C has class="smalltext" (not a colspan header) + shiftPattern := regexp.MustCompile( + `([^<]+)]*>([^<]+)`, + ) + + employees := lo.Map(employeeMatches, func(empMatch []string, _ int) Employee { + name := strings.TrimSpace(empMatch[1]) + innerTable := empMatch[2] + + shiftMatches := shiftPattern.FindAllStringSubmatch(innerTable, -1) + + shifts := lo.FilterMap(shiftMatches, func(sm []string, _ int) (Shift, bool) { + codeAndDesc := strings.TrimSpace(sm[1]) + hoursStr := strings.TrimSpace(sm[2]) + + parts := strings.SplitN(codeAndDesc, " - ", 2) + if len(parts) != 2 { + return Shift{}, false + } + + hours, err := strconv.ParseFloat(hoursStr, 64) + if err != nil { + return Shift{}, false + } + + return Shift{ + EarningCode: strings.TrimSpace(parts[0]), + Description: strings.TrimSpace(parts[1]), + Hours: hours, + }, true + }) + + return Employee{ + Name: name, + Shifts: shifts, + } + }) + + return employees, nil +} diff --git a/internal/utils/parse_xls_file_test.go b/internal/utils/parse_xls_file_test.go new file mode 100644 index 0000000..32afd75 --- /dev/null +++ b/internal/utils/parse_xls_file_test.go @@ -0,0 +1,77 @@ +package utils_test + +import ( + "os" + + "clintonambulance.com/calculate_negative_points/internal/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseXLSContent", func() { + It("parses employees and their shifts from the test XLS file", func() { + content, err := os.ReadFile("testdata/test.xls") + Expect(err).NotTo(HaveOccurred()) + + employees, err := utils.ParseXLSContent(content) + Expect(err).NotTo(HaveOccurred()) + Expect(employees).To(HaveLen(2)) + + By("parsing the first employee") + Expect(employees[0].Name).To(Equal("Howe, Eugene")) + Expect(employees[0].Worked()).To(BeTrue()) + Expect(employees[0].Shifts).To(HaveLen(2)) + Expect(employees[0].Shifts[0]).To(Equal(utils.Shift{ + EarningCode: "MEET", + Description: "Meetings", + Hours: 1.75, + })) + Expect(employees[0].Shifts[0].CountsAsWorked()).To(BeFalse()) + Expect(employees[0].Shifts[1]).To(Equal(utils.Shift{ + EarningCode: "Reg", + Description: "Regular Hours", + Hours: 125.75, + })) + Expect(employees[0].Shifts[1].CountsAsWorked()).To(BeTrue()) + + By("parsing the second employee with multiple shift categories") + Expect(employees[1].Name).To(Equal("User, Test")) + Expect(employees[1].Shifts).To(HaveLen(4)) + Expect(employees[1].Shifts[0]).To(Equal(utils.Shift{ + EarningCode: "MEET", + Description: "Meetings", + Hours: 2.00, + })) + Expect(employees[1].Shifts[1]).To(Equal(utils.Shift{ + EarningCode: "Reg", + Description: "Regular Hours", + Hours: 134.00, + })) + Expect(employees[1].Shifts[2]).To(Equal(utils.Shift{ + EarningCode: "Holiday", + Description: "Holiday Hours", + Hours: 8.00, + })) + Expect(employees[1].Shifts[3]).To(Equal(utils.Shift{ + EarningCode: "PTO", + Description: "Paid Time Off", + Hours: 12.00, + })) + }) + + It("skips category header rows (colspan rows)", func() { + content, err := os.ReadFile("testdata/test.xls") + Expect(err).NotTo(HaveOccurred()) + + employees, err := utils.ParseXLSContent(content) + Expect(err).NotTo(HaveOccurred()) + + Expect(employees).To(HaveLen(2)) + }) + + It("returns an empty slice for empty content", func() { + employees, err := utils.ParseXLSContent([]byte("")) + Expect(err).NotTo(HaveOccurred()) + Expect(employees).To(BeEmpty()) + }) +}) diff --git a/internal/utils/random_string.go b/internal/utils/random_string.go new file mode 100644 index 0000000..ca93cae --- /dev/null +++ b/internal/utils/random_string.go @@ -0,0 +1,14 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" +) + +func RandomString(n int) (string, error) { + bytes := make([]byte, n) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/utils/schema/openfeature_flag.go b/internal/utils/schema/openfeature_flag.go new file mode 100644 index 0000000..9c1eea2 --- /dev/null +++ b/internal/utils/schema/openfeature_flag.go @@ -0,0 +1,11 @@ +package schemautils + +type OpenfeatureFlag struct { + Name string `json:"name" required:"true" description:"Name of the flag"` + Id string `json:"id" required:"true" description:"UUID of the flag"` + Kind string `json:"kind" required:"true" description:"Flag kind" enum:"boolean,number,string,string_array,int_array,float_array"` + Enabled bool `json:"enabled" required:"true" description:"This is hardcoded to true"` + Variant string `json:"variant" required:"true" description:"The name of the variant that is assigned to the appclient environment"` + Variants map[string]interface{} `json:"variants" required:"true" description:"All variants and their values" nullable:"false"` + Default string `json:"default" required:"true" description:"Name of the default variant assigned to the flag"` +} diff --git a/internal/utils/to_title_case.go b/internal/utils/to_title_case.go new file mode 100644 index 0000000..a1c012b --- /dev/null +++ b/internal/utils/to_title_case.go @@ -0,0 +1,24 @@ +package utils + +import "strings" + +func ToTitleCase(name string) string { + words := strings.Fields(name) + for i, word := range words { + if len(word) > 0 { + // Handle hyphenated names + if strings.Contains(word, "-") { + parts := strings.Split(word, "-") + for j, part := range parts { + if len(part) > 0 { + parts[j] = strings.ToUpper(string(part[0])) + strings.ToLower(part[1:]) + } + } + words[i] = strings.Join(parts, "-") + } else { + words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) + } + } + } + return strings.Join(words, " ") +} diff --git a/internal/utils/utils_suite_test.go b/internal/utils/utils_suite_test.go new file mode 100644 index 0000000..9ca82ff --- /dev/null +++ b/internal/utils/utils_suite_test.go @@ -0,0 +1,13 @@ +package utils_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +} diff --git a/internal/views/web.html b/internal/views/web.html new file mode 100644 index 0000000..ef47e3a --- /dev/null +++ b/internal/views/web.html @@ -0,0 +1,37 @@ + + + + + Calculate Negative Points + + + + + +
+ + {{ if .isDev }} + + + + + {{ else }} + + {{ range .css }} + + {{ end }} + {{ end }} + + + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..3ca3710 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,160 @@ +package web + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + + "clintonambulance.com/calculate_negative_points/internal/config" + "github.com/go-chi/chi/v5" + "github.com/swaggest/rest/web" + "go.uber.org/zap" +) + +type ManifestEntry struct { + Src string `json:"src"` + File string `json:"file"` + IsEntry bool `json:"isEntry"` + Css []string `json:"css"` +} + +func withEntryPoint(config *config.ApplicationConfig, ep string, tmplParams map[string]interface{}) { + + if config.Environment == "dev" { + tmplParams["jsFileAddress"] = fmt.Sprintf("%s/%s", tmplParams["baseAddress"], ep) + } else { + var parsedManifest map[string]ManifestEntry + manifestContent, err := os.ReadFile(filepath.Join(config.PublicPath, ".vite", "manifest.json")) + + if err != nil { + log.Fatal(err) + } + + err = json.Unmarshal(manifestContent, &parsedManifest) + + if err != nil { + log.Fatal(err) + } + + tmplParams["jsFileAddress"] = parsedManifest[ep].File + tmplParams["css"] = parsedManifest[ep].Css + } + +} + +func MountWebEndpoints(e *web.Service, config *config.ApplicationConfig, logger *zap.Logger) { + rootResponder := func(w http.ResponseWriter, r *http.Request) { + tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "web.html")) + + tmplParams := map[string]interface{}{ + "baseAddress": "http://localhost:5173", + "isDev": config.Environment == "dev", + "environment": config.Environment, + } + + withEntryPoint(config, "src/main.tsx", tmplParams) + + if err != nil { + log.Println(err) + http.Error(w, "Something went wrong", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, tmplParams); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + loggedOutResponder := func(w http.ResponseWriter, r *http.Request) { + tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "logged_out.html")) + + if err != nil { + http.Error(w, "Something went wrong", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, &struct{}{}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + notAuthorizedResponder := func(w http.ResponseWriter, r *http.Request) { + tmpl, err := template.ParseFiles(filepath.Join(config.ViewPath, "auth_error.html")) + + if err != nil { + http.Error(w, "Something went wrong", http.StatusInternalServerError) + return + } + + if err := tmpl.Execute(w, &struct{}{}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + loginHandler := func(w http.ResponseWriter, r *http.Request) { + returnTo := r.URL.Query().Get("returnTo") + if returnTo == "" { + returnTo = "/" // fallback + } + + session, _ := config.CookieStore.Get(r, config.SessionName) + session.Values["return_to"] = returnTo + session.Save(r, w) + + state := "random-state" // Replace with actual CSRF protection + http.Redirect(w, r, config.OidcConfig.OAuth2.AuthCodeURL(state), http.StatusFound) + } + + callbackHandler := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := r.URL.Query().Get("code") + + token, err := config.OidcConfig.OAuth2.Exchange(ctx, code) + if err != nil { + http.Error(w, "Token exchange failed", http.StatusInternalServerError) + return + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "Missing id_token", http.StatusInternalServerError) + return + } + + _, err = config.OidcConfig.Verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(w, "Invalid ID token", http.StatusUnauthorized) + return + } + + session, _ := config.CookieStore.Get(r, config.SessionName) + + if refreshToken, ok := token.Extra("refresh_token").(string); ok { + session.Values["refresh_token"] = refreshToken + } + + returnTo, ok := session.Values["return_to"].(string) + if !ok || returnTo == "" { + returnTo = "/" + } + session.Values["id_token"] = rawIDToken + session.Save(r, w) + + http.Redirect(w, r, returnTo, http.StatusFound) + } + + e.Wrapper.Get("/users/logged_out", loggedOutResponder) + e.Wrapper.Get("/403", notAuthorizedResponder) + e.Wrapper.Post("/403", notAuthorizedResponder) + + e.Route("/", func(r chi.Router) { + r.HandleFunc("/", rootResponder) + r.Get("/auth/login", loginHandler) + r.Get("/auth/callback", callbackHandler) + r.MethodFunc(http.MethodGet, "/*", rootResponder) + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..49146e5 --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "clintonambulance.com/calculate_negative_points/cmd" + "clintonambulance.com/calculate_negative_points/internal/config" +) + +func main() { + cmd.Execute(config.NewVersion()) +}