diff --git a/.gitignore b/.gitignore index 9890de43..ab66fe80 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,28 @@ -local.yaml \ No newline at end of file +local.yaml +.env + +# 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/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 00000000..5ddb70ed --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + + "os/signal" + "syscall" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/app" + "github.com/joshsoftware/code-curiosity-2025/internal/app/cronJob" + "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/db" + "github.com/rs/cors" + "github.com/urfave/cli" +) + +func main() { + cfg, err := config.LoadAppConfig() + if err != nil { + slog.Error("error loading app config", "error", err) + return + } + + cliApp := cli.NewApp() + cliApp.Name = cfg.AppName + cliApp.Version = "1.0.0" + cliApp.Commands = []cli.Command{ + { + Name: "start", + Usage: "Start HTTP server", + Action: func(c *cli.Context) error { + return startApp(cfg) + }, + }, + { + Name: "migrate", + Usage: "Database migrations", + Subcommands: []cli.Command{ + { + Name: "up", + Usage: "Apply migrations", + Action: func(c *cli.Context) error { + m, _ := db.InitMainDBMigrations(cfg) + m.MigrationsUp(c.Args().First()) + return nil + }, + }, + { + Name: "down", + Usage: "Rollback migrations", + Action: func(c *cli.Context) error { + m, _ := db.InitMainDBMigrations(cfg) + m.MigrationsDown(c.Args().First()) + return nil + }, + }, + { + Name: "create", + Usage: "Create a new migration file", + Action: func(c *cli.Context) error { + m, _ := db.InitMainDBMigrations(cfg) + return m.CreateMigrationFile(c.Args().First()) + }, + }, + }, + }, + } + + if err := cliApp.Run(os.Args); err != nil { + panic(err) + } +} + +func startApp(cfg config.AppConfig) error { + ctx := context.Background() + + slog.Info("Starting CodeCuriosity Application...") + + db, err := config.InitDataStore(cfg) + if err != nil { + slog.Error("error initializing database", "error", err) + return err + } + defer db.Close() + + bigqueryInstance, err := config.BigqueryInit(ctx, cfg) + if err != nil { + slog.Error("error initializing bigquery", "error", err) + return err + } + + httpClient := &http.Client{} + + dependencies := app.InitDependencies(db, cfg, bigqueryInstance, httpClient) + + router := app.NewRouter(dependencies) + + newCronSchedular := cronJob.NewCronSchedular() + newCronSchedular.InitCronJobs(dependencies.ContributionService, dependencies.UserService) + + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowCredentials: true, + AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}, + AllowedHeaders: []string{"*"}, + }) + + server := http.Server{ + Addr: fmt.Sprintf(":%s", cfg.HTTPServer.Port), + Handler: router, + } + + server.Handler = c.Handler(server.Handler) + + serverRunning := make(chan os.Signal, 1) + + signal.Notify( + serverRunning, + syscall.SIGABRT, + syscall.SIGALRM, + syscall.SIGBUS, + syscall.SIGINT, + syscall.SIGTERM, + ) + + go func() { + slog.Info("server listening at", "port", cfg.HTTPServer.Port) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + serverRunning <- syscall.SIGINT + } + }() + + <-serverRunning + + slog.Info("shutting down the server") + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + slog.Error("cannot shut HTTP server down gracefully", "error", err) + } + + slog.Info("server shutdown successfully") + return nil +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 00000000..6cdfd1ee --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,72 @@ +module github.com/joshsoftware/code-curiosity-2025 + +go 1.23.4 + +require ( + cloud.google.com/go/bigquery v1.68.0 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/golang-migrate/migrate/v4 v4.18.3 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/jmoiron/sqlx v1.4.0 + github.com/lib/pq v1.10.9 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.37.0 + golang.org/x/oauth2 v0.29.0 + google.golang.org/api v0.231.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/urfave/cli v1.22.17 // indirect +) + +require ( + cloud.google.com/go v0.121.0 // indirect + cloud.google.com/go/auth v0.16.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.6.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/apache/arrow/go/v15 v15.0.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/pierrec/lz4/v4 v4.1.18 // indirect + github.com/rs/cors v1.11.1 + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.23.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect + golang.org/x/time v0.11.0 // indirect + golang.org/x/tools v0.30.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect + google.golang.org/grpc v1.72.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 00000000..9f239732 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,233 @@ +cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= +cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= +cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= +cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= +cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/bigquery v1.68.0 h1:F+CPqdcMxZGUDBACzGtOJ1E6E0MWSYcKeFthxnhpYIU= +cloud.google.com/go/bigquery v1.68.0/go.mod h1:1UAksG8IFXJomQV38xUsRB+2m2c1H9U0etvoGHgyhDk= +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/datacatalog v1.26.0 h1:eFgygb3DTufTWWUB8ARk+dSuXz+aefNJXTlkWlQcWwE= +cloud.google.com/go/datacatalog v1.26.0/go.mod h1:bLN2HLBAwB3kLTFT5ZKLHVPj/weNz6bR0c7nYp0LE14= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= +cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= +cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw= +cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= +github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk= +github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= +github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= +github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +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/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= +github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= +github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +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/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= +github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= +gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= +google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q= +google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34 h1:0PeQib/pH3nB/5pEmFeVQJotzGohV0dq4Vcp09H5yhE= +google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/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.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/backend/internal/app/auth/domain.go b/backend/internal/app/auth/domain.go new file mode 100644 index 00000000..c6c7575f --- /dev/null +++ b/backend/internal/app/auth/domain.go @@ -0,0 +1,51 @@ +package auth + +import ( + "database/sql" + "time" +) + +const ( + LoginWithGithubFailed = "LoginWithGithubFailed" + AccessTokenCookieName = "AccessToken" + GitHubOAuthState = "state" + GithubOauthScope = "read:user" + GetUserGithubUrl = "https://api.github.com/user" + GetUserEmailUrl = "https://api.github.com/user/emails" +) + +type User struct { + Id int `json:"userId"` + GithubId int `json:"githubId"` + GithubUsername string `json:"githubUsername"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + CurrentBalance int `json:"currentBalance"` + CurrentActiveGoalId sql.NullInt64 `json:"currentActiveGoalId"` + IsBlocked bool `json:"isBlocked"` + IsAdmin bool `json:"isAdmin"` + Password string `json:"password"` + IsDeleted bool `json:"isDeleted"` + DeletedAt sql.NullTime `json:"deletedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type GithubUserResponse struct { + GithubId int `json:"id"` + GithubUsername string `json:"login"` + AvatarUrl string `json:"avatar_url"` + Email string `json:"email"` + IsAdmin bool `json:"is_admin"` + IsBlocked bool `json:"is_blocked"` +} + +type AdminLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type Admin struct { + User + JwtToken string `json:"jwtToken"` +} diff --git a/internal/app/auth/handler.go b/backend/internal/app/auth/handler.go similarity index 51% rename from internal/app/auth/handler.go rename to backend/internal/app/auth/handler.go index 2be51640..249a0527 100644 --- a/internal/app/auth/handler.go +++ b/backend/internal/app/auth/handler.go @@ -1,30 +1,32 @@ package auth import ( - "fmt" + "encoding/json" "log/slog" "net/http" "github.com/joshsoftware/code-curiosity-2025/internal/config" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" ) type handler struct { authService Service - appConfig config.AppConfig + appConfig config.AppConfig } type Handler interface { GithubOAuthLoginUrl(w http.ResponseWriter, r *http.Request) GithubOAuthLoginCallback(w http.ResponseWriter, r *http.Request) GetLoggedInUser(w http.ResponseWriter, r *http.Request) + LoginAdmin(w http.ResponseWriter, r *http.Request) } func NewHandler(authService Service, appConfig config.AppConfig) Handler { return &handler{ authService: authService, - appConfig: appConfig, + appConfig: appConfig, } } @@ -44,25 +46,26 @@ func (h *handler) GithubOAuthLoginCallback(w http.ResponseWriter, r *http.Reques token, err := h.authService.GithubOAuthLoginCallback(ctx, code) if err != nil { slog.Error("failed to login with github", "error", err) - http.Redirect(w, r, fmt.Sprintf("%s?authError=%s", h.appConfig.ClientURL, LoginWithGithubFailed), http.StatusTemporaryRedirect) + response.WriteJson(w, http.StatusUnauthorized, "failed to log in with github", nil) return } - cookie := &http.Cookie{ - Name: AccessTokenCookieName, - Value: token, - //TODO set domain before deploying to production - // Domain: "yourdomain.com", - HttpOnly: true, - } - http.SetCookie(w, cookie) - http.Redirect(w, r, h.appConfig.ClientURL, http.StatusPermanentRedirect) + response.WriteJson(w, http.StatusOK, "successfully logged in with github", token) } func (h *handler) GetLoggedInUser(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - userInfo, err := h.authService.GetLoggedInUser(ctx) + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + userInfo, err := h.authService.GetLoggedInUser(ctx, userId) if err != nil { slog.Error("error getting logged in user") status, errorMessage := apperrors.MapError(err) @@ -72,3 +75,25 @@ func (h *handler) GetLoggedInUser(w http.ResponseWriter, r *http.Request) { response.WriteJson(w, http.StatusOK, "logged in user fetched successfully", userInfo) } + +func (h *handler) LoginAdmin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var requestBody AdminLoginRequest + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + adminInfo, err := h.authService.VerifyAdminCredentials(ctx, requestBody) + if err != nil { + slog.Error("failed to verify admin credentials", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "admin logged in successfully", adminInfo) +} diff --git a/internal/app/auth/service.go b/backend/internal/app/auth/service.go similarity index 65% rename from internal/app/auth/service.go rename to backend/internal/app/auth/service.go index 3bca6c0d..a35a751a 100644 --- a/internal/app/auth/service.go +++ b/backend/internal/app/auth/service.go @@ -9,7 +9,7 @@ import ( "github.com/joshsoftware/code-curiosity-2025/internal/config" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/jwt" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) @@ -23,7 +23,8 @@ type service struct { type Service interface { GithubOAuthLoginUrl(ctx context.Context) string GithubOAuthLoginCallback(ctx context.Context, code string) (string, error) - GetLoggedInUser(ctx context.Context) (User, error) + GetLoggedInUser(ctx context.Context, userId int) (User, error) + VerifyAdminCredentials(ctx context.Context, adminCredentials AdminLoginRequest) (Admin, error) } func NewService(userService user.Service, appCfg config.AppConfig) Service { @@ -77,24 +78,24 @@ func (s *service) GithubOAuthLoginCallback(ctx context.Context, code string) (st } } - jwtToken, err := jwt.GenerateJWT(userData.Id, userInfo.IsAdmin, s.appCfg) + jwtToken, err := jwt.GenerateJWT(userData.Id, userInfo.IsAdmin, userData.IsBlocked, s.appCfg) if err != nil { slog.Error("error generating jwt", "error", err) return "", apperrors.ErrInternalServer } + if userData.IsDeleted { + err = s.userService.RecoverAccountInGracePeriod(ctx, userData.Id) + if err != nil { + slog.Error("error in recovering account in grace period during login", "error", err) + return "", apperrors.ErrInternalServer + } + } + return jwtToken, nil } -func (s *service) GetLoggedInUser(ctx context.Context) (User, error) { - userIdValue := ctx.Value(middleware.UserIdKey) - - userId, ok := userIdValue.(int) - if !ok { - slog.Error("error obtaining user id from context") - return User{}, apperrors.ErrInternalServer - } - +func (s *service) GetLoggedInUser(ctx context.Context, userId int) (User, error) { user, err := s.userService.GetUserById(ctx, userId) if err != nil { slog.Error("failed to get logged in user", "error", err) @@ -103,3 +104,30 @@ func (s *service) GetLoggedInUser(ctx context.Context) (User, error) { return User(user), nil } + +func (s *service) VerifyAdminCredentials(ctx context.Context, adminCredentials AdminLoginRequest) (Admin, error) { + adminInfo, err := s.userService.GetLoggedInAdmin(ctx, user.AdminLoginRequest(adminCredentials)) + if err != nil { + slog.Error("failed to verify admin", "error", err) + return Admin{}, err + } + + err = bcrypt.CompareHashAndPassword([]byte(adminInfo.Password), []byte(adminCredentials.Password)) + if err != nil { + slog.Error("failed to verify admin, invalid password", "error", err) + return Admin{}, apperrors.ErrInvalidCredentials + } + + jwtToken, err := jwt.GenerateJWT(adminInfo.Id, adminInfo.IsAdmin, adminInfo.IsBlocked, s.appCfg) + if err != nil { + slog.Error("failed to generate jwt token", "error", err) + return Admin{}, apperrors.ErrInternalServer + } + + admin := Admin{ + User: User(adminInfo), + JwtToken: jwtToken, + } + + return admin, nil +} diff --git a/backend/internal/app/badge/domain.go b/backend/internal/app/badge/domain.go new file mode 100644 index 00000000..3811923a --- /dev/null +++ b/backend/internal/app/badge/domain.go @@ -0,0 +1,12 @@ +package badge + +import "time" + +type Badge struct { + Id int `json:"id"` + UserId int `json:"userId"` + BadgeType string `json:"badgeType"` + EarnedAt time.Time `json:"earnedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/app/badge/handler.go b/backend/internal/app/badge/handler.go new file mode 100644 index 00000000..0cd456dd --- /dev/null +++ b/backend/internal/app/badge/handler.go @@ -0,0 +1,48 @@ +package badge + +import ( + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +type handler struct { + badgeService Service +} + +type Handler interface { + GetBadgeDetailsOfUser(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(badgeService Service) Handler { + return &handler{ + badgeService: badgeService, + } +} + +func (h *handler) GetBadgeDetailsOfUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMsg := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMsg, nil) + return + } + + badges, err := h.badgeService.GetBadgeDetailsOfUser(ctx, userId) + + if err != nil { + slog.Error("failed to get badge details of user", "Error", err) + status, errorMsg := apperrors.MapError(err) + response.WriteJson(w, status, errorMsg, nil) + return + } + + response.WriteJson(w, http.StatusOK, "badges fetched successfully", badges) +} diff --git a/backend/internal/app/badge/service.go b/backend/internal/app/badge/service.go new file mode 100644 index 00000000..817ca283 --- /dev/null +++ b/backend/internal/app/badge/service.go @@ -0,0 +1,59 @@ +package badge + +import ( + "context" + "database/sql" + "errors" + "log/slog" + + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + badgeRepository repository.BadgeRepository +} + +type Service interface { + HandleBadgeCreation(ctx context.Context, userId int, badgeType string) (Badge, error) + GetBadgeDetailsOfUser(ctx context.Context, userId int) ([]Badge, error) +} + +func NewService(badgeRepository repository.BadgeRepository) Service { + return &service{ + badgeRepository: badgeRepository, + } +} + +func (s *service) HandleBadgeCreation(ctx context.Context, userId int, badgeType string) (Badge, error) { + badge, err := s.badgeRepository.GetUserCurrentMonthBadge(ctx, nil, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + badge, err = s.badgeRepository.CreateBadge(ctx, nil, userId, badgeType) + if err != nil { + slog.Error("error creating badge for user", "error", err) + return Badge{}, err + } + } + slog.Error("error fetching current month badge for user", "error", err) + return Badge{}, err + } + + return Badge(badge), nil +} + +func (s *service) GetBadgeDetailsOfUser(ctx context.Context, userId int) ([]Badge, error) { + badges, err := s.badgeRepository.GetBadgeDetailsOfUser(ctx, nil, userId) + + if err != nil { + slog.Error("(service) Failed to get the badge details", "error", err) + return nil, err + } + + serviceBadge := make([]Badge, len(badges)) + + for i, b := range badges { + serviceBadge[i] = Badge(b) + } + + return serviceBadge, nil +} diff --git a/backend/internal/app/bigquery/domain.go b/backend/internal/app/bigquery/domain.go new file mode 100644 index 00000000..70a08b33 --- /dev/null +++ b/backend/internal/app/bigquery/domain.go @@ -0,0 +1,45 @@ +package bigquery + +import "time" + +const DailyQuery = `SELECT + id, + type, + public, + actor.id AS actor_id, + actor.login AS actor_login, + actor.gravatar_id AS actor_gravatar_id, + actor.url AS actor_url, + actor.avatar_url AS actor_avatar_url, + repo.id AS repo_id, + repo.name AS repo_name, + repo.url AS repo_url, + payload, + created_at, + other +FROM + githubarchive.day.%s +WHERE + type IN ( + 'IssuesEvent', + 'PullRequestEvent', + 'PullRequestReviewEvent', + 'IssueCommentEvent', + 'PullRequestReviewCommentEvent', + 'PushEvent' + ) + AND ( + actor.id IN (%s) + )` + +type ContributionResponse struct { + ID string `bigquery:"id"` + Type string `bigquery:"type"` + ActorID int `bigquery:"actor_id"` + ActorLogin string `bigquery:"actor_login"` + RepoID int `bigquery:"repo_id"` + RepoName string `bigquery:"repo_name"` + RepoUrl string `bigquery:"repo_url"` + Payload string `bigquery:"payload"` + CreatedAt time.Time `bigquery:"created_at"` +} diff --git a/backend/internal/app/bigquery/service.go b/backend/internal/app/bigquery/service.go new file mode 100644 index 00000000..21a20644 --- /dev/null +++ b/backend/internal/app/bigquery/service.go @@ -0,0 +1,53 @@ +package bigquery + +import ( + "context" + "fmt" + "log/slog" + "time" + + bq "cloud.google.com/go/bigquery" + "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/utils" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + bigqueryInstance config.Bigquery + userRepository repository.UserRepository +} + +type Service interface { + FetchDailyContributions(ctx context.Context) (*bq.RowIterator, error) +} + +func NewService(bigqueryInstance config.Bigquery, userRepository repository.UserRepository) Service { + return &service{ + bigqueryInstance: bigqueryInstance, + userRepository: userRepository, + } +} + +func (s *service) FetchDailyContributions(ctx context.Context) (*bq.RowIterator, error) { + usersGithubId, err := s.userRepository.GetAllUsersGithubId(ctx, nil) + if err != nil { + slog.Error("error fetching users github usernames") + return nil, apperrors.ErrInternalServer + } + + formattedGithubIds := utils.FormatIntSliceForQuery(usersGithubId) + + YesterdayDate := time.Now().AddDate(0, 0, -1) + YesterdayYearMonthDay := YesterdayDate.Format("20060102") + fetchDailyContributionsQuery := fmt.Sprintf(DailyQuery, YesterdayYearMonthDay, formattedGithubIds) + + bigqueryQuery := s.bigqueryInstance.Client.Query(fetchDailyContributionsQuery) + contributionRows, err := bigqueryQuery.Read(ctx) + if err != nil { + slog.Error("error fetching contributions", "error", err) + return nil, err + } + + return contributionRows, err +} diff --git a/backend/internal/app/contribution/domain.go b/backend/internal/app/contribution/domain.go new file mode 100644 index 00000000..0fc11c51 --- /dev/null +++ b/backend/internal/app/contribution/domain.go @@ -0,0 +1,80 @@ +package contribution + +import "time" + +type ContributionResponse struct { + ID string `bigquery:"id" json:"id"` + Type string `bigquery:"type" json:"type"` + ActorID int `bigquery:"actor_id" json:"actorId"` + ActorLogin string `bigquery:"actor_login" json:"actorLogin"` + RepoID int `bigquery:"repo_id" json:"repoId"` + RepoName string `bigquery:"repo_name" json:"repoName"` + RepoUrl string `bigquery:"repo_url" json:"repoUrl"` + Payload string `bigquery:"payload" json:"payload"` + CreatedAt time.Time `bigquery:"created_at" json:"createdAt"` +} + +type Repository struct { + Id int `json:"id"` + GithubRepoId int `json:"githubRepoId"` + RepoName string `json:"repoName"` + Description string `json:"description"` + LanguagesUrl string `json:"languagesUrl"` + RepoUrl string `json:"repoUrl"` + OwnerName string `json:"ownerName"` + UpdateDate time.Time `json:"updateDate"` + ContributorsUrl string `json:"contributorsUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Contribution struct { + Id int `json:"id"` + UserId int `json:"userId"` + RepositoryId int `json:"repositoryId"` + ContributionScoreId int `json:"contributionScoreId"` + ContributionType string `json:"contributionType"` + BalanceChange int `json:"balanceChange"` + ContributedAt time.Time `json:"contributedAt"` + GithubEventId string `json:"githubEventId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ContributionScore struct { + Id int `json:"id"` + AdminId int `json:"adminId"` + ContributionType string `json:"contributionType"` + Score int `json:"score"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Transaction struct { + Id int `json:"id"` + UserId int `json:"userId"` + ContributionId int `json:"contributionId"` + IsRedeemed bool `json:"isRedeemed"` + IsGained bool `json:"isGained"` + TransactedBalance int `json:"transactedBalance"` + TransactedAt time.Time `json:"transactedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type MonthlyContributionSummary struct { + Type string `json:"type"` + Count int `json:"count"` + TotalCoins int `json:"totalCoins"` + Month time.Time `json:"month"` +} + +type FetchUserContributionsResponse struct { + Contribution + Repository +} + +type ConfigureContributionTypeScore struct { + ContributionType string `json:"contributionType"` + Score int `json:"score"` +} \ No newline at end of file diff --git a/backend/internal/app/contribution/handler.go b/backend/internal/app/contribution/handler.go new file mode 100644 index 00000000..dfac84c2 --- /dev/null +++ b/backend/internal/app/contribution/handler.go @@ -0,0 +1,138 @@ +package contribution + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/utils" +) + +type handler struct { + contributionService Service +} + +type Handler interface { + FetchUserContributions(w http.ResponseWriter, r *http.Request) + ListMonthlyContributionSummary(w http.ResponseWriter, r *http.Request) + ListAllContributionTypes(w http.ResponseWriter, r *http.Request) + ConfigureContributionTypeScore(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(contributionService Service) Handler { + return &handler{ + contributionService: contributionService, + } +} + +func (h *handler) FetchUserContributions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + userContributions, err := h.contributionService.FetchUserContributions(ctx, userId) + if err != nil { + slog.Error("error fetching user contributions", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user contributions fetched successfully", userContributions) +} + +func (h *handler) ListMonthlyContributionSummary(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + yearVal := r.URL.Query().Get("year") + year, err := utils.ValidateYearQueryParam(yearVal) + if err != nil { + slog.Error("error converting year value to integer", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + monthVal := r.URL.Query().Get("month") + month, err := utils.ValidateMonthQueryParam(monthVal) + if err != nil { + slog.Error("error converting month value to integer", "error", err) + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + monthlyContributionSummary, err := h.contributionService.ListMonthlyContributionSummary(ctx, year, month, userId) + if err != nil { + slog.Error("error fetching contribution type summary for month", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "contribution type overview for month fetched successfully", monthlyContributionSummary) +} + +func (h *handler) ListAllContributionTypes(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + contributionTypes, err := h.contributionService.ListAllContributionTypes(ctx) + if err != nil { + slog.Error("error fetching all contribution types", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "all contribution types fetched successfully", contributionTypes) +} + +func (h *handler) ConfigureContributionTypeScore(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // isAdminValue := ctx.Value(middleware.IsAdminKey) + // isAdmin, ok := isAdminValue.(bool) + // if !ok { + // slog.Error("error verifying admin from context") + // status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + // response.WriteJson(w, status, errorMessage, nil) + // return + // } + + var configureContributionTypeScores []ConfigureContributionTypeScore + err := json.NewDecoder(r.Body).Decode(&configureContributionTypeScores) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + contributionTypeScores, err := h.contributionService.ConfigureContributionTypeScore(ctx, configureContributionTypeScores) + if err != nil { + slog.Error("error configuring contribution type scores", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "contribution types fscores configured successfully", contributionTypeScores) +} diff --git a/backend/internal/app/contribution/service.go b/backend/internal/app/contribution/service.go new file mode 100644 index 00000000..69a6c2f1 --- /dev/null +++ b/backend/internal/app/contribution/service.go @@ -0,0 +1,408 @@ +package contribution + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/bigquery" + "github.com/joshsoftware/code-curiosity-2025/internal/app/goal" + repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" + "github.com/joshsoftware/code-curiosity-2025/internal/app/transaction" + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" + "google.golang.org/api/iterator" +) + +// github event names +const ( + pullRequestEvent = "PullRequestEvent" + issuesEvent = "IssuesEvent" + issueCommentEvent = "IssueCommentEvent" + pullRequestCommentEvent = "PullRequestReviewCommentEvent" + pullRequestReviewEvent = "PullRequestReviewEvent" + pushEvent = "PushEvent" +) + +// app contribution types +const ( + pullRequestMerged = "PullRequestMerged" + pullRequestOpened = "PullRequestOpened" + issueOpened = "IssueOpened" + issueClosed = "IssueClosed" + issueResolved = "IssueResolved" + issueComment = "IssueComment" + pullRequestComment = "PullRequestComment" + pullRequestReviewed = "PullRequestReviewed" + CommitAdded = "CommitAdded" +) + +// payload +const ( + payloadActionKey = "action" + payloadPullRequestKey = "pull_request" + PayloadMergedKey = "merged" + PayloadIssueKey = "issue" + PayloadStateReasonKey = "state_reason" + PayloadClosedKey = "closed" + PayloadOpenedKey = "opened" + PayloadNotPlannedKey = "not_planned" + PayloadCompletedKey = "completed" + PayloadCreatedKey = "created" + PayloadApprovedKey = "approved" +) + +type service struct { + bigqueryService bigquery.Service + contributionRepository repository.ContributionRepository + repositoryService repoService.Service + userService user.Service + transactionService transaction.Service + goalService goal.Service + httpClient *http.Client +} + +type Service interface { + ProcessFetchedContributions(ctx context.Context) error + ProcessEachContribution(ctx context.Context, contribution ContributionResponse) error + GetContributionType(ctx context.Context, contribution ContributionResponse) (string, error) + CreateContribution(ctx context.Context, contributionType string, contributionDetails ContributionResponse, repositoryId int, userId int) (Contribution, error) + HandleContributionCreation(ctx context.Context, repositoryID int, contribution ContributionResponse) (Contribution, error) + GetContributionScoreDetailsByContributionType(ctx context.Context, contributionType string) (ContributionScore, error) + FetchUserContributions(ctx context.Context, userId int) ([]FetchUserContributionsResponse, error) + GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) + ListMonthlyContributionSummary(ctx context.Context, year int, monthParam int, userId int) ([]MonthlyContributionSummary, error) + ListAllContributionTypes(ctx context.Context) ([]ContributionScore, error) + ConfigureContributionTypeScore(ctx context.Context, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) + HandleGoalSynchronization(ctx context.Context, userId int) error +} + +func NewService(bigqueryService bigquery.Service, contributionRepository repository.ContributionRepository, repositoryService repoService.Service, userService user.Service, transactionService transaction.Service, goalService goal.Service, httpClient *http.Client) Service { + return &service{ + bigqueryService: bigqueryService, + contributionRepository: contributionRepository, + repositoryService: repositoryService, + userService: userService, + transactionService: transactionService, + goalService: goalService, + httpClient: httpClient, + } +} + +func (s *service) ProcessFetchedContributions(ctx context.Context) error { + contributions, err := s.bigqueryService.FetchDailyContributions(ctx) + if err != nil { + slog.Error("error fetching daily contributions", "error", err) + return apperrors.ErrFetchingFromBigquery + } + + //using a local copy here to copy contribution so that I can implement retry mechanism in future + //thinking of batch processing to be implemented later on, to handle memory overflow + var fetchedContributions []ContributionResponse + + for { + var contribution ContributionResponse + err := contributions.Next(&contribution) + if err != nil { + if err == iterator.Done { + break + } + + slog.Error("error iterating contribution rows", "error", err) + return apperrors.ErrNextContribution + } + + fetchedContributions = append(fetchedContributions, contribution) + } + + for _, contribution := range fetchedContributions { + err := s.ProcessEachContribution(ctx, contribution) + if err != nil { + slog.Error("error processing contribution with github event id", "github event id", "error", contribution.ID, err) + continue + } + } + + users, err := s.userService.ListAllUsers(ctx) + if err != nil { + slog.Error("error fetching all users", "error", err) + return err + } + + for _, user := range users { + err := s.HandleGoalSynchronization(ctx, user.Id) + if err != nil { + slog.Error("error handling goal synchronization for user", "user id", user.Id, "error", err) + continue + } + } + + return nil +} + +func (s *service) ProcessEachContribution(ctx context.Context, contribution ContributionResponse) error { + obtainedContribution, err := s.GetContributionByGithubEventId(ctx, contribution.ID) + if err != nil { + if err == apperrors.ErrContributionNotFound { + obtainedRepository, err := s.repositoryService.HandleRepositoryCreation(ctx, repoService.ContributionResponse(contribution)) + if err != nil { + slog.Error("error handling repository creation", "error", err) + return err + } + obtainedContribution, err = s.HandleContributionCreation(ctx, obtainedRepository.Id, contribution) + if err != nil { + slog.Error("error handling contribution creation", "error", err) + return err + } + } else { + slog.Error("error fetching contribution by github event id", "error", err) + return err + } + } + + _, err = s.transactionService.HandleTransactionCreation(ctx, transaction.Contribution(obtainedContribution)) + if err != nil { + slog.Error("error handling transaction creation", "error", err) + return err + } + + return nil +} + +func (s *service) GetContributionType(ctx context.Context, contribution ContributionResponse) (string, error) { + var contributionPayload map[string]interface{} + err := json.Unmarshal([]byte(contribution.Payload), &contributionPayload) + if err != nil { + slog.Warn("invalid payload", "error", err) + return "", err + } + + var action string + if actionVal, ok := contributionPayload[payloadActionKey]; ok { + action = actionVal.(string) + } + + var pullRequest map[string]interface{} + var isMerged bool + if pullRequestPayload, ok := contributionPayload[payloadPullRequestKey]; ok { + pullRequest = pullRequestPayload.(map[string]interface{}) + if isMergedVal, ok := pullRequest[PayloadMergedKey]; ok { + isMerged = isMergedVal.(bool) + } + } + + var issue map[string]interface{} + var stateReason string + if issuePayload, ok := contributionPayload[PayloadIssueKey]; ok { + issue = issuePayload.(map[string]interface{}) + if stateReasonVal, ok := issue[PayloadStateReasonKey]; ok && stateReasonVal != nil { + if v, ok := stateReasonVal.(string); ok { + stateReason = v + } + } + } + + var contributionType string + switch contribution.Type { + case pullRequestEvent: + if action == PayloadClosedKey && isMerged { + contributionType = pullRequestMerged + } else if action == PayloadOpenedKey { + contributionType = pullRequestOpened + } + + case issuesEvent: + if action == PayloadOpenedKey { + contributionType = issueOpened + } else if action == PayloadClosedKey && stateReason == PayloadNotPlannedKey { + contributionType = issueClosed + } else if action == PayloadClosedKey && stateReason == PayloadCompletedKey { + contributionType = issueResolved + } + + case pushEvent: + contributionType = CommitAdded + + case pullRequestReviewEvent: + if action == PayloadCreatedKey || action == PayloadApprovedKey { + contributionType = pullRequestReviewed + } + + case issueCommentEvent: + contributionType = issueComment + + //if user.login not equal to contribution login + case pullRequestCommentEvent: + if action == PayloadCreatedKey { + contributionType = pullRequestComment + } + } + + return contributionType, nil +} + +func (s *service) CreateContribution(ctx context.Context, contributionType string, contributionDetails ContributionResponse, repositoryId int, userId int) (Contribution, error) { + contribution := Contribution{ + UserId: userId, + RepositoryId: repositoryId, + ContributionType: contributionType, + ContributedAt: contributionDetails.CreatedAt, + GithubEventId: contributionDetails.ID, + } + + contributionScoreDetails, err := s.GetContributionScoreDetailsByContributionType(ctx, contributionType) + if err != nil { + slog.Error("error occured while getting contribution score details", "error", err) + return Contribution{}, err + } + + contribution.ContributionScoreId = contributionScoreDetails.Id + contribution.BalanceChange = contributionScoreDetails.Score + + contributionResponse, err := s.contributionRepository.CreateContribution(ctx, nil, repository.Contribution(contribution)) + if err != nil { + slog.Error("error creating contribution", "error", err) + return Contribution{}, err + } + + return Contribution(contributionResponse), nil +} + +func (s *service) HandleContributionCreation(ctx context.Context, repositoryID int, contribution ContributionResponse) (Contribution, error) { + user, err := s.userService.GetUserByGithubId(ctx, contribution.ActorID) + if err != nil { + slog.Error("error getting user id", "error", err) + return Contribution{}, err + } + + contributionType, err := s.GetContributionType(ctx, contribution) + if err != nil || contributionType == "" { + slog.Error("error getting contribution type", "error", err) + return Contribution{}, err + } + + obtainedContribution, err := s.CreateContribution(ctx, contributionType, contribution, repositoryID, user.Id) + if err != nil { + slog.Error("error creating contribution", "error", err) + return Contribution{}, err + } + + return obtainedContribution, nil +} + +func (s *service) GetContributionScoreDetailsByContributionType(ctx context.Context, contributionType string) (ContributionScore, error) { + contributionScoreDetails, err := s.contributionRepository.GetContributionScoreDetailsByContributionType(ctx, nil, contributionType) + if err != nil { + slog.Error("error occured while getting contribution score details", "error", err) + return ContributionScore{}, err + } + + return ContributionScore(contributionScoreDetails), nil +} + +func (s *service) FetchUserContributions(ctx context.Context, userId int) ([]FetchUserContributionsResponse, error) { + userContributions, err := s.contributionRepository.FetchUserContributions(ctx, nil, userId) + if err != nil { + slog.Error("error occured while fetching user contributions", "error", err) + return nil, err + } + + serviceContributions := make([]FetchUserContributionsResponse, len(userContributions)) + for i, c := range userContributions { + serviceContributions[i].Contribution = Contribution(c) + fetchContributedRepository, err := s.repositoryService.GetRepoByRepoId(ctx, c.RepositoryId) + if err != nil { + slog.Error("error occured while fetching users contributed repository details", "error", err) + return nil, err + } + + serviceContributions[i].Repository = Repository(fetchContributedRepository) + } + + return serviceContributions, nil +} + +func (s *service) GetContributionByGithubEventId(ctx context.Context, githubEventId string) (Contribution, error) { + contribution, err := s.contributionRepository.GetContributionByGithubEventId(ctx, nil, githubEventId) + if err != nil { + slog.Error("error fetching contribution by github event id", "error", err) + return Contribution{}, err + } + + return Contribution(contribution), nil +} + +func (s *service) ListMonthlyContributionSummary(ctx context.Context, year int, month int, userId int) ([]MonthlyContributionSummary, error) { + MonthlyContributionSummaries, err := s.contributionRepository.ListMonthlyContributionSummary(ctx, nil, year, month, userId) + if err != nil { + slog.Error("error fetching monthly contribution summary", "error", err) + return nil, err + } + + serviceMonthlyContributionSummaries := make([]MonthlyContributionSummary, len(MonthlyContributionSummaries)) + + for i, c := range MonthlyContributionSummaries { + serviceMonthlyContributionSummaries[i] = MonthlyContributionSummary(c) + } + + return serviceMonthlyContributionSummaries, nil +} + +func (s *service) ListAllContributionTypes(ctx context.Context) ([]ContributionScore, error) { + contributionTypes, err := s.contributionRepository.GetAllContributionTypes(ctx, nil) + if err != nil { + slog.Error("error fetching all contribution types", "error", err) + return nil, err + } + + serviceContributionTypes := make([]ContributionScore, len(contributionTypes)) + for i, c := range contributionTypes { + serviceContributionTypes[i] = ContributionScore(c) + } + + return serviceContributionTypes, nil +} + +func (s *service) ConfigureContributionTypeScore(ctx context.Context, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) { + repoConfigureContributionScore := make([]repository.ConfigureContributionTypeScore, len(configureContributionTypeScore)) + for i, c := range configureContributionTypeScore { + repoConfigureContributionScore[i] = repository.ConfigureContributionTypeScore(c) + } + + contributionTypeScores, err := s.contributionRepository.UpdateContributionTypeScore(ctx, nil, repoConfigureContributionScore) + if err != nil { + slog.Error("error updating contritbution types score", "error", err) + return nil, err + } + + serviceContributionTypeScores := make([]ContributionScore, len(contributionTypeScores)) + for i, c := range contributionTypeScores { + serviceContributionTypeScores[i] = ContributionScore(c) + } + + return serviceContributionTypeScores, nil +} + +func (s *service) HandleGoalSynchronization(ctx context.Context, userId int) error { + err := s.goalService.SyncUserGoalProgressWithContributions(ctx, userId) + if err != nil { + slog.Error("error syncing goal progress with contibutions", "error", err) + return err + } + + err = s.goalService.AllocateBadge(ctx, userId) + if err != nil { + slog.Error("error allocating badge", "error", err) + return err + } + + _, err = s.goalService.CreateUserGoalSummary(ctx, userId) + if err != nil { + slog.Error("error creating goal summary for user", "error", err) + return err + } + + return nil +} diff --git a/backend/internal/app/cronJob/cleanupJob.go b/backend/internal/app/cronJob/cleanupJob.go new file mode 100644 index 00000000..d87b3c99 --- /dev/null +++ b/backend/internal/app/cronJob/cleanupJob.go @@ -0,0 +1,32 @@ +package cronJob + +import ( + "context" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" +) + +type CleanupJob struct { + CronJob + userService user.Service +} + +func NewCleanupJob(userService user.Service) *CleanupJob { + return &CleanupJob{ + userService: userService, + CronJob: CronJob{Name: "User Cleanup Job Daily"}, + } +} + +func (c *CleanupJob) Schedule(s *CronSchedular) error { + _, err := s.cron.AddFunc("00 18 * * *", func() { c.Execute(context.Background(), c.run) }) + if err != nil { + return err + } + + return nil +} + +func (c *CleanupJob) run(ctx context.Context) { + c.userService.HardDeleteUsers(ctx) +} diff --git a/backend/internal/app/cronJob/cronjob.go b/backend/internal/app/cronJob/cronjob.go new file mode 100644 index 00000000..f8f3a219 --- /dev/null +++ b/backend/internal/app/cronJob/cronjob.go @@ -0,0 +1,24 @@ +package cronJob + +import ( + "context" + "log/slog" + "time" +) + +type Job interface { + Schedule(c *CronSchedular) error +} + +type CronJob struct { + Name string +} + +func (c *CronJob) Execute(ctx context.Context, fn func(context.Context)) { + slog.Info("cron job started at", "time ", time.Now()) + defer func() { + slog.Info("cron job completed") + }() + + fn(ctx) +} diff --git a/backend/internal/app/cronJob/dailyJob.go b/backend/internal/app/cronJob/dailyJob.go new file mode 100644 index 00000000..7ab7f55f --- /dev/null +++ b/backend/internal/app/cronJob/dailyJob.go @@ -0,0 +1,32 @@ +package cronJob + +import ( + "context" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" +) + +type DailyJob struct { + CronJob + contributionService contribution.Service +} + +func NewDailyJob(contributionService contribution.Service) *DailyJob { + return &DailyJob{ + contributionService: contributionService, + CronJob: CronJob{Name: "Fetch Contributions Daily"}, + } +} + +func (d *DailyJob) Schedule(s *CronSchedular) error { + _, err := s.cron.AddFunc("0 7 * * *", func() { d.Execute(context.Background(), d.run) }) + if err != nil { + return err + } + + return nil +} + +func (d *DailyJob) run(ctx context.Context) { + d.contributionService.ProcessFetchedContributions(ctx) +} diff --git a/backend/internal/app/cronJob/init.go b/backend/internal/app/cronJob/init.go new file mode 100644 index 00000000..a08e8de8 --- /dev/null +++ b/backend/internal/app/cronJob/init.go @@ -0,0 +1,36 @@ +package cronJob + +import ( + "log/slog" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" + "github.com/robfig/cron/v3" +) + +type CronSchedular struct { + cron *cron.Cron +} + +func NewCronSchedular() *CronSchedular { + //CHANGE AND SET TO UTC TIMEZONE + return &CronSchedular{ + cron: cron.New(cron.WithLocation(time.UTC)), + } +} + +func (c *CronSchedular) InitCronJobs(contributionService contribution.Service, userService user.Service) { + jobs := []Job{ + NewDailyJob(contributionService), + NewCleanupJob(userService), + } + + for _, job := range jobs { + if err := job.Schedule(c); err != nil { + slog.Error("failed to execute cron job") + } + } + + c.cron.Start() +} diff --git a/backend/internal/app/cronJob/monthlyJob.go b/backend/internal/app/cronJob/monthlyJob.go new file mode 100644 index 00000000..ed546e2e --- /dev/null +++ b/backend/internal/app/cronJob/monthlyJob.go @@ -0,0 +1,32 @@ +package cronJob + +import ( + "context" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/goal" +) + +type MonthlyJob struct { + CronJob + goalService goal.Service +} + +func NewMonthlyJob(goalService goal.Service) *MonthlyJob { + return &MonthlyJob{ + goalService: goalService, + CronJob: CronJob{Name: "Update User Goal Status"}, + } +} + +func (m *MonthlyJob) Schedule(s *CronSchedular) error { + _, err := s.cron.AddFunc("0 10 2 * *", func() { m.Execute(context.Background(), m.run) }) + if err != nil { + return err + } + + return nil +} + +func (m *MonthlyJob) run(ctx context.Context) { + m.goalService.UpdateUserGoalStatusMonthly(ctx) +} diff --git a/backend/internal/app/dependencies.go b/backend/internal/app/dependencies.go new file mode 100644 index 00000000..7b7983b7 --- /dev/null +++ b/backend/internal/app/dependencies.go @@ -0,0 +1,71 @@ +package app + +import ( + "net/http" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/app/auth" + "github.com/joshsoftware/code-curiosity-2025/internal/app/badge" + "github.com/joshsoftware/code-curiosity-2025/internal/app/bigquery" + "github.com/joshsoftware/code-curiosity-2025/internal/app/contribution" + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" + "github.com/joshsoftware/code-curiosity-2025/internal/app/goal" + repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" + "github.com/joshsoftware/code-curiosity-2025/internal/app/transaction" + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" + "github.com/joshsoftware/code-curiosity-2025/internal/config" + + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type Dependencies struct { + ContributionService contribution.Service + UserService user.Service + AuthHandler auth.Handler + UserHandler user.Handler + ContributionHandler contribution.Handler + RepositoryHandler repoService.Handler + GoalHandler goal.Handler + BadgeHandler badge.Handler + AppCfg config.AppConfig + Client config.Bigquery +} + +func InitDependencies(db *sqlx.DB, appCfg config.AppConfig, client config.Bigquery, httpClient *http.Client) Dependencies { + badgeRepository := repository.NewBadgeRepository(db) + goalRepository := repository.NewGoalRepository(db) + userRepository := repository.NewUserRepository(db) + contributionRepository := repository.NewContributionRepository(db) + repositoryRepository := repository.NewRepositoryRepository(db) + transactionRepository := repository.NewTransactionRepository(db) + + githubService := github.NewService(appCfg, httpClient) + repositoryService := repoService.NewService(repositoryRepository, githubService) + badgeService := badge.NewService(badgeRepository) + goalService := goal.NewService(goalRepository, contributionRepository, badgeService) + userService := user.NewService(userRepository, goalService, repositoryService) + authService := auth.NewService(userService, appCfg) + bigqueryService := bigquery.NewService(client, userRepository) + transactionService := transaction.NewService(transactionRepository, userService) + contributionService := contribution.NewService(bigqueryService, contributionRepository, repositoryService, userService, transactionService, goalService, httpClient) + + authHandler := auth.NewHandler(authService, appCfg) + userHandler := user.NewHandler(userService) + repositoryHandler := repoService.NewHandler(repositoryService, githubService) + contributionHandler := contribution.NewHandler(contributionService) + goalHandler := goal.NewHandler(goalService) + badgeHandler := badge.NewHandler(badgeService) + + return Dependencies{ + ContributionService: contributionService, + UserService: userService, + AuthHandler: authHandler, + UserHandler: userHandler, + RepositoryHandler: repositoryHandler, + ContributionHandler: contributionHandler, + GoalHandler: goalHandler, + BadgeHandler: badgeHandler, + AppCfg: appCfg, + Client: client, + } +} diff --git a/backend/internal/app/github/domain.go b/backend/internal/app/github/domain.go new file mode 100644 index 00000000..94ab5438 --- /dev/null +++ b/backend/internal/app/github/domain.go @@ -0,0 +1,38 @@ +package github + +import "time" + +const AuthorizationKey = "Authorization" + +type RepoOwner struct { + Login string `json:"login"` +} + +type FetchRepositoryDetailsResponse struct { + Id int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + LanguagesURL string `json:"languages_url"` + UpdateDate time.Time `json:"updated_at"` + RepoOwnerName RepoOwner `json:"owner"` + ContributorsUrl string `json:"contributors_url"` + RepoUrl string `json:"html_url"` +} + +type RepoLanguages map[string]int + +type RepoContributorsResponse struct { + Id int `json:"id"` + Name string `json:"login"` + AvatarUrl string `json:"avatar_url"` + GithubUrl string `json:"html_url"` + Contributions int `json:"contributions"` +} + +type FetchRepositoryContributorsResponse struct { + Id int `json:"id"` + Name string `json:"name"` + AvatarUrl string `json:"avatar_url"` + GithubUrl string `json:"github_url"` + Contributions int `json:"contributions"` +} diff --git a/backend/internal/app/github/service.go b/backend/internal/app/github/service.go new file mode 100644 index 00000000..1ec62eff --- /dev/null +++ b/backend/internal/app/github/service.go @@ -0,0 +1,98 @@ +package github + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/utils" +) + +type service struct { + appCfg config.AppConfig + httpClient *http.Client +} + +type Service interface { + configureGithubApiHeaders() map[string]string + FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) + FetchRepositoryLanguages(ctx context.Context, getRepoLanguagesURL string) (RepoLanguages, error) + FetchRepositoryContributors(ctx context.Context, getRepoContributorsURl string) ([]FetchRepositoryContributorsResponse, error) +} + +func NewService(appCfg config.AppConfig, httpClient *http.Client) Service { + return &service{ + appCfg: appCfg, + httpClient: httpClient, + } +} + +func (s *service) configureGithubApiHeaders() map[string]string { + return map[string]string{ + AuthorizationKey: s.appCfg.GithubPersonalAccessToken, + } +} + +func (s *service) FetchRepositoryDetails(ctx context.Context, getUserRepoDetailsUrl string) (FetchRepositoryDetailsResponse, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getUserRepoDetailsUrl, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return FetchRepositoryDetailsResponse{}, err + } + + var repoDetails FetchRepositoryDetailsResponse + err = json.Unmarshal(body, &repoDetails) + if err != nil { + slog.Error("error unmarshalling fetch repository details body", "error", err) + return FetchRepositoryDetailsResponse{}, err + } + + return repoDetails, nil +} + +func (s *service) FetchRepositoryLanguages(ctx context.Context, getRepoLanguagesURL string) (RepoLanguages, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getRepoLanguagesURL, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return RepoLanguages{}, err + } + + var repoLanguages RepoLanguages + err = json.Unmarshal(body, &repoLanguages) + if err != nil { + slog.Error("error unmarshalling fetch repository languages body", "error", err) + return RepoLanguages{}, err + } + + return repoLanguages, nil +} + +func (s *service) FetchRepositoryContributors(ctx context.Context, getRepoContributorsURl string) ([]FetchRepositoryContributorsResponse, error) { + headers := s.configureGithubApiHeaders() + + body, err := utils.DoGet(s.httpClient, getRepoContributorsURl, headers) + if err != nil { + slog.Error("error making a GET request", "error", err) + return nil, err + } + + var repoContributors []RepoContributorsResponse + err = json.Unmarshal(body, &repoContributors) + if err != nil { + slog.Error("error unmarshalling fetch contributors body", "error", err) + return nil, err + } + + serviceRepoContributors := make([]FetchRepositoryContributorsResponse, len(repoContributors)) + for i, c := range repoContributors { + serviceRepoContributors[i] = FetchRepositoryContributorsResponse(c) + } + + return serviceRepoContributors, nil +} diff --git a/backend/internal/app/goal/domain.go b/backend/internal/app/goal/domain.go new file mode 100644 index 00000000..7edee439 --- /dev/null +++ b/backend/internal/app/goal/domain.go @@ -0,0 +1,114 @@ +package goal + +import "time" + +const ( + GoalStatusInProgress = "inProgress" + GoalStatusCompleted = "completed" + GoalStatusIncomplete = "incomplete" +) + +const ( + GoalLevelBeginner = "Beginner" + GoalLevelIntermediate = "Intermediate" + GoalLevelAdvanced = "Advanced" + GoalLevelCustom = "Custom" +) + +type GoalLevel struct { + Id int `json:"id"` + Level string `json:"level"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type GoalLevelName struct { + Level string `json:"level"` +} + +type UserGoal struct { + Id int `json:"id"` + UserId int `json:"userId"` + GoalLevelId int `json:"goalLevelId"` + Status string `json:"status"` + MonthStartedAt time.Time `json:"monthStartedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserGoalTarget struct { + Id int `json:"id"` + UserGoalId int `json:"userGoalId"` + ContributionScoreId int `json:"contributionScoreId"` + Target int `json:"target"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserGoalProgress struct { + UserGoalTargetId int `json:"userGoalTargetId"` + ContributionId int `json:"contributionId"` +} + +type Contribution struct { + Id int `json:"id"` + UserId int `json:"userId"` + RepositoryId int `json:"repositoryId"` + ContributionScoreId int `json:"contributionScoreId"` + ContributionType string `json:"contributionType"` + BalanceChange int `json:"balanceChange"` + ContributedAt time.Time `json:"contributedAt"` + GithubEventId string `json:"githubEventId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateUserGoalRequest struct { + Level string `json:"level"` + CustomTargets []CustomTargetRequest `json:"customTargets,omitempty"` +} + +type CustomTargetRequest struct { + ContributionType string `json:"contributionType"` + Target int `json:"target"` +} + +type GetUserCurrentGoalStatusResponse struct { + UserGoalId int `json:"userGoalId"` + Level string `json:"level"` + Status string `json:"status"` + MonthStartedAt time.Time `json:"monthStartedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + GoalTargetProgress []UserGoalTargetProgress `json:"goalTargetProgress"` +} + +type UserGoalTargetProgress struct { + ContributionType string `json:"contributionType"` + Target int `json:"target"` + Progress int `json:"progress"` +} + +type UserGoalIdRequest struct { + UserGoalId int `json:"userGoalId"` +} + +type GoalSummary struct { + Id int `json:"id"` + UserId int `json:"userId"` + SnapshotDate time.Time `json:"snapshotDate"` + IncompleteGoalsCount int `json:"incompleteGoalsCount"` + TargetSet int `json:"targetSet"` + TargetCompleted int `json:"targetCompleted"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type GoalLevelTarget struct { + Id int `json:"id"` + GoalLevelId int `json:"goalLevelId"` + ContributionType string `json:"contributionType"` + Target int `json:"target"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/app/goal/handler.go b/backend/internal/app/goal/handler.go new file mode 100644 index 00000000..451863fd --- /dev/null +++ b/backend/internal/app/goal/handler.go @@ -0,0 +1,166 @@ +package goal + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +type handler struct { + goalService Service +} + +type Handler interface { + ListGoalLevels(w http.ResponseWriter, r *http.Request) + FetchGoalLevelTargetByGoalLevel(w http.ResponseWriter, r *http.Request) + CreateUserGoalInProgress(w http.ResponseWriter, r *http.Request) + ResetUserCurrentGoalStatus(w http.ResponseWriter, r *http.Request) + GetUserCurrentGoalStatus(w http.ResponseWriter, r *http.Request) + FetchUserMonthlyGoalSummary(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(goalService Service) Handler { + return &handler{ + goalService: goalService, + } +} + +func (h *handler) ListGoalLevels(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + gaols, err := h.goalService.ListGoalLevels(ctx) + if err != nil { + slog.Error("error fetching goal levels", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "goal levels fetched successfully", gaols) +} + +func (h *handler) FetchGoalLevelTargetByGoalLevel(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var goalLevel GoalLevel + err := json.NewDecoder(r.Body).Decode(&goalLevel) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + goalLevelTargets, err := h.goalService.FetchGoalLevelTargetByGoalLevel(ctx, goalLevel) + if err != nil { + slog.Error("error fetching goal level targets by goal level", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "goal level targets fetched successfully", goalLevelTargets) +} + +func (h *handler) CreateUserGoalInProgress(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdCtxVal := ctx.Value(middleware.UserIdKey) + userId, ok := userIdCtxVal.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + var userSelecetdGoal CreateUserGoalRequest + err := json.NewDecoder(r.Body).Decode(&userSelecetdGoal) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + userGoal, err := h.goalService.CreateUserGoalInProgress(ctx, userSelecetdGoal, userId) + if err != nil { + slog.Error("failed to create user goal status", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "Goal created successfully", userGoal) +} + +func (h *handler) ResetUserCurrentGoalStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdCtxVal := ctx.Value(middleware.UserIdKey) + userId, ok := userIdCtxVal.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + userResetGoalStatus, err := h.goalService.ResetUserCurrentGoalStatus(ctx, userId) + if err != nil { + slog.Error("error resetting user current goal status", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user current goal status reset successfully", userResetGoalStatus) +} + +func (h *handler) GetUserCurrentGoalStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdCtxVal := ctx.Value(middleware.UserIdKey) + userId, ok := userIdCtxVal.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + userCurrentGoalStatus, err := h.goalService.GetUserCurrentGoalStatus(ctx, userId) + if err != nil { + slog.Error("error getting current goal status for user", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user current goal status fetched successfully", userCurrentGoalStatus) +} + +func (h *handler) FetchUserMonthlyGoalSummary(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdCtxVal := ctx.Value(middleware.UserIdKey) + userId, ok := userIdCtxVal.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + userMonthlyGoalSummary, err := h.goalService.FetchUserGoalSummary(ctx, userId) + if err != nil { + slog.Error("error etching user monthly goal summary", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user monthly goal summary fetched successfully", userMonthlyGoalSummary) +} diff --git a/backend/internal/app/goal/service.go b/backend/internal/app/goal/service.go new file mode 100644 index 00000000..f23ccaf3 --- /dev/null +++ b/backend/internal/app/goal/service.go @@ -0,0 +1,505 @@ +package goal + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/badge" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + goalRepository repository.GoalRepository + contributionRepository repository.ContributionRepository + badgeService badge.Service +} + +type Service interface { + ListGoalLevels(ctx context.Context) ([]GoalLevel, error) + FetchGoalLevelTargetByGoalLevel(ctx context.Context, goalLevel GoalLevel) ([]GoalLevelTarget, error) + CreateUserGoalInProgress(ctx context.Context, userSelecetdGoal CreateUserGoalRequest, userId int) (UserGoal, error) + CreateCustomUserGoalTarget(ctx context.Context, userSelectedCustomGoals []CustomTargetRequest, createdUserGoal UserGoal) ([]UserGoalTarget, error) + SyncUserGoalProgress(ctx context.Context, userGoalTargets []UserGoalTarget, monthStartedAt time.Time, userId int) ([]UserGoalProgress, error) + ResetUserCurrentGoalStatus(ctx context.Context, userId int) (UserGoal, error) + GetUserCurrentGoalStatus(ctx context.Context, userId int) (*GetUserCurrentGoalStatusResponse, error) + AllocateBadge(ctx context.Context, userId int) error + UpdateUserGoalStatusMonthly(ctx context.Context) error + SyncUserGoalProgressWithContributions(ctx context.Context, userId int) error + CreateUserGoalSummary(ctx context.Context, userId int) (GoalSummary, error) + FetchUserGoalSummary(ctx context.Context, userId int) ([]GoalSummary, error) +} + +func NewService(goalRepository repository.GoalRepository, contributionRepository repository.ContributionRepository, badgeService badge.Service) Service { + return &service{ + goalRepository: goalRepository, + contributionRepository: contributionRepository, + badgeService: badgeService, + } +} + +func (s *service) ListGoalLevels(ctx context.Context) ([]GoalLevel, error) { + goals, err := s.goalRepository.ListGoalLevels(ctx, nil) + if err != nil { + slog.Error("error fetching goal levels", "error", err) + return nil, err + } + + serviceGoals := make([]GoalLevel, len(goals)) + for i, g := range goals { + serviceGoals[i] = GoalLevel(g) + } + + return serviceGoals, nil +} + +func (s *service) FetchGoalLevelTargetByGoalLevel(ctx context.Context, goalLevel GoalLevel) ([]GoalLevelTarget, error) { + goalLevelTargets, err := s.goalRepository.FetchGoalLevelTargetByGoalLevel(ctx, nil, repository.GoalLevel(goalLevel)) + if err != nil { + slog.Error("error fetching goal level target by goal level", "error", err) + return nil, err + } + + serviceGoalLevelTargets := make([]GoalLevelTarget, len(goalLevelTargets)) + for i, g := range goalLevelTargets { + contributionType, err := s.contributionRepository.GetContributionTypeByContributionScoreId(ctx, nil, g.ContributionScoreId) + if err != nil { + slog.Error("error fetching contribution type by contribution score id", "error", err) + return nil, err + } + + serviceGoalLevelTargets[i] = GoalLevelTarget{ + Id: g.Id, + GoalLevelId: g.GoalLevelId, + ContributionType: contributionType, + Target: g.Target, + CreatedAt: g.CreatedAt, + UpdatedAt: g.UpdatedAt, + } + } + + return serviceGoalLevelTargets, nil +} + +func (s *service) CreateUserGoalInProgress(ctx context.Context, userSelecetdGoal CreateUserGoalRequest, userId int) (UserGoal, error) { + now := time.Now().UTC() + monthStartedAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + + userCurrentGoal, err := s.goalRepository.GetUserCurrentGoal(ctx, nil, userId) + if err == nil { + slog.Warn("user already has existing goal set for current month") + return UserGoal(userCurrentGoal), apperrors.ErrUserGoalExists + } else if !errors.Is(err, apperrors.ErrUserGoalNotFound) { + slog.Error("error getting user goal for current month", "error", err) + return UserGoal{}, err + } + + goalLevel, err := s.goalRepository.GetGoalLevelByLevel(ctx, nil, userSelecetdGoal.Level) + if err != nil { + slog.Error("error fetching goal id by goal level", "error", err) + return UserGoal{}, err + } + + userGoal := UserGoal{ + UserId: userId, + GoalLevelId: goalLevel.Id, + Status: GoalStatusInProgress, + MonthStartedAt: monthStartedAt, + } + + createdUserGoal, err := s.goalRepository.CreateUserGoalInProgress(ctx, nil, repository.UserGoal(userGoal)) + if err != nil { + slog.Error("failed to create user goal status", "error", err) + return UserGoal{}, err + } + + var createdUserGoalTargets []UserGoalTarget + + //check if custom + if userSelecetdGoal.Level == GoalLevelCustom { + createdUserGoalTargets, err = s.CreateCustomUserGoalTarget(ctx, userSelecetdGoal.CustomTargets, UserGoal(createdUserGoal)) + if err != nil { + slog.Error("error creating custom goal target", "error", err) + return UserGoal{}, err + } + } + + //check if goal level is not custom + if userSelecetdGoal.Level != GoalLevelCustom { + goalLevelTargets, err := s.goalRepository.FetchGoalLevelTargetByGoalLevelId(ctx, nil, goalLevel) + if err != nil { + slog.Error("error fetching goal level target", "error", err) + return UserGoal{}, err + } + + for _, g := range goalLevelTargets { + userGoalTarget := UserGoalTarget{ + UserGoalId: createdUserGoal.Id, + ContributionScoreId: g.ContributionScoreId, + Target: g.Target, + } + + createdUserGoalTarget, err := s.goalRepository.CreateUserGoalTarget(ctx, nil, repository.UserGoalTarget(userGoalTarget)) + if err != nil { + slog.Error("error creeating user goal target", "error", err) + return UserGoal{}, err + } + + createdUserGoalTargets = append(createdUserGoalTargets, UserGoalTarget(createdUserGoalTarget)) + } + } + + _, err = s.SyncUserGoalProgress(ctx, createdUserGoalTargets, monthStartedAt, userId) + if err != nil { + slog.Error("error syncing user goal progress", "error", err) + return UserGoal{}, err + } + + return UserGoal(createdUserGoal), nil +} + +func (s *service) CreateCustomUserGoalTarget(ctx context.Context, userSelectedCustomGoals []CustomTargetRequest, createdUserGoal UserGoal) ([]UserGoalTarget, error) { + createdUserGoalTargets := make([]UserGoalTarget, len(userSelectedCustomGoals)) + + for _, userSelectedCustomGoal := range userSelectedCustomGoals { + contributionScoreDetails, err := s.contributionRepository.GetContributionScoreDetailsByContributionType(ctx, nil, userSelectedCustomGoal.ContributionType) + if err != nil { + slog.Error("error getting contirbution score details for given contribution type", "error", err) + return nil, err + } + + userGoalTarget := UserGoalTarget{ + UserGoalId: createdUserGoal.Id, + ContributionScoreId: contributionScoreDetails.Id, + Target: userSelectedCustomGoal.Target, + } + + createdUserGoalTarget, err := s.goalRepository.CreateUserGoalTarget(ctx, nil, repository.UserGoalTarget(userGoalTarget)) + if err != nil { + slog.Error("error creeating user goal target", "error", err) + return nil, err + } + + createdUserGoalTargets = append(createdUserGoalTargets, UserGoalTarget(createdUserGoalTarget)) + } + + return createdUserGoalTargets, nil +} + +func (s *service) SyncUserGoalProgress(ctx context.Context, userGoalTargets []UserGoalTarget, monthStartedAt time.Time, userId int) ([]UserGoalProgress, error) { + userContributionsForMonth, err := s.contributionRepository.FetchUserContributionsForMonth(ctx, nil, userId, monthStartedAt) + if err != nil { + slog.Error("error fetching user contributions for month", "error", err) + return nil, err + } + + contributionMap := make(map[int][]Contribution) + for _, c := range userContributionsForMonth { + contributionMap[c.ContributionScoreId] = append(contributionMap[c.ContributionScoreId], Contribution(c)) + } + + var createdUserGoalProgresses []UserGoalProgress + + for _, target := range userGoalTargets { + if contributions, ok := contributionMap[target.ContributionScoreId]; ok { + for _, contribution := range contributions { + userGoalProgress := UserGoalProgress{ + UserGoalTargetId: target.Id, + ContributionId: contribution.Id, + } + + created, err := s.goalRepository.CreateUserGoalProgress(ctx, nil, repository.UserGoalProgress(userGoalProgress)) + if err != nil { + slog.Error("error creating user goal progress", "error", err) + continue + } + + createdUserGoalProgresses = append(createdUserGoalProgresses, UserGoalProgress(created)) + } + } + } + + return createdUserGoalProgresses, nil +} + +func (s *service) ResetUserCurrentGoalStatus(ctx context.Context, userId int) (UserGoal, error) { + userCurrentGoal, err := s.goalRepository.GetUserCurrentGoal(ctx, nil, userId) + if err != nil { + slog.Error("error getting user goal for current month") + return UserGoal{}, err + } + + if time.Since(userCurrentGoal.CreatedAt) > 48*time.Hour || userCurrentGoal.Status != GoalStatusInProgress { + slog.Error("cannot reset goal", "error", err) + return UserGoal{}, apperrors.ErrFailedResettingGoal + } + + userGoal := UserGoal{ + Id: userCurrentGoal.Id, + Status: GoalStatusIncomplete, + } + updatedUserGoal, err := s.goalRepository.UpdateUserGoalStatus(ctx, nil, repository.UserGoal(userGoal)) + if err != nil { + slog.Error("error updating goal status for user", "error", err) + return UserGoal{}, err + } + + return UserGoal(updatedUserGoal), nil +} + +func (s *service) GetUserCurrentGoalStatus(ctx context.Context, userId int) (*GetUserCurrentGoalStatusResponse, error) { + userCurrentGoal, err := s.goalRepository.GetUserCurrentGoal(ctx, nil, userId) + if err != nil { + slog.Error("error getting user goal for current month", "error", err) + return nil, err + } + + goalLevel, err := s.goalRepository.GetGoalLevelById(ctx, nil, userCurrentGoal.GoalLevelId) + if err != nil { + slog.Error("error fetching goal leve by goal level id", "error", err) + return nil, err + } + + userCurrentGoalTargets, err := s.goalRepository.ListUserGoalTargetsByUserGoalId(ctx, nil, userCurrentGoal.Id) + if err != nil { + slog.Error("error fetching user goal targets by user goal id", "error", err) + return nil, err + } + + goalTargetProgresses := make([]UserGoalTargetProgress, 0, len(userCurrentGoalTargets)) + + var totalTargetsCompleted int + totalTargets := len(userCurrentGoalTargets) + + for _, userCurrentGoalTarget := range userCurrentGoalTargets { + + contributionType, err := s.contributionRepository.GetContributionTypeByContributionScoreId(ctx, nil, userCurrentGoalTarget.ContributionScoreId) + if err != nil { + slog.Error("error fetching contribution type by contribution score id", "error", err) + return nil, err + } + + contributionProgressCount, err := s.goalRepository.GetContributionProgressCount(ctx, nil, userCurrentGoalTarget.Id) + if err != nil { + slog.Error("error fetching contribution progress count", "error", err) + return nil, err + } + + goalTargetProgress := UserGoalTargetProgress{ + ContributionType: contributionType, + Target: userCurrentGoalTarget.Target, + Progress: contributionProgressCount, + } + + if goalTargetProgress.Target <= goalTargetProgress.Progress { + totalTargetsCompleted++ + } + + goalTargetProgresses = append(goalTargetProgresses, goalTargetProgress) + } + + userCurrentGoalStatusResponse := GetUserCurrentGoalStatusResponse{ + UserGoalId: userCurrentGoal.Id, + Level: goalLevel.Level, + Status: userCurrentGoal.Status, + MonthStartedAt: userCurrentGoal.MonthStartedAt, + CreatedAt: userCurrentGoal.UpdatedAt, + UpdatedAt: userCurrentGoal.UpdatedAt, + GoalTargetProgress: goalTargetProgresses, + } + + if totalTargets <= totalTargetsCompleted { + userGoal := UserGoal{ + Id: userCurrentGoal.Id, + Status: GoalStatusCompleted, + } + _, err = s.goalRepository.UpdateUserGoalStatus(ctx, nil, repository.UserGoal(userGoal)) + if err != nil { + slog.Error("error updating user goal status to complete", "error", err) + return nil, err + } + + _, err = s.badgeService.HandleBadgeCreation(ctx, userId, goalLevel.Level) + if err != nil { + slog.Error("error creating badge", "error", err) + return nil, err + } + } + + return &userCurrentGoalStatusResponse, nil +} + +func (s *service) SyncUserGoalProgressWithContributions(ctx context.Context, userId int) error { + userCurrentGoal, err := s.goalRepository.GetUserCurrentGoal(ctx, nil, userId) + if err != nil { + if errors.Is(err, apperrors.ErrUserGoalNotFound) { + slog.Info("user does not have active goal for current month, skipping goal synchronization") + return nil + } + + slog.Error("error getting user goal for current month", "error", err) + return err + } + + userCurrentGoalTargets, err := s.goalRepository.ListUserGoalTargetsByUserGoalId(ctx, nil, userCurrentGoal.Id) + if err != nil { + slog.Error("error fetching user goal targets by user goal id", "error", err) + return err + } + + serviceUserCurrentGoalTargets := make([]UserGoalTarget, len(userCurrentGoalTargets)) + for i, userCurrentGoalTarget := range userCurrentGoalTargets { + serviceUserCurrentGoalTargets[i] = UserGoalTarget(userCurrentGoalTarget) + } + + now := time.Now().UTC() + monthStartedAt := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + _, err = s.SyncUserGoalProgress(ctx, serviceUserCurrentGoalTargets, monthStartedAt, userId) + if err != nil { + slog.Error("error syncing user goal progress with contributions", "error", err) + return err + } + + return nil +} + +func (s *service) AllocateBadge(ctx context.Context, userId int) error { + userCurrentGoalStatus, err := s.GetUserCurrentGoalStatus(ctx, userId) + if err != nil { + if errors.Is(err, apperrors.ErrUserGoalNotFound) { + slog.Info("user does not have active goal for current month, skipping badge allocation") + return nil + } + + slog.Error("error fetching user current goal status", "error", err) + return err + } + + var totalTargetsCompleted int + totalTargets := len(userCurrentGoalStatus.GoalTargetProgress) + for _, goalTargetProgress := range userCurrentGoalStatus.GoalTargetProgress { + if goalTargetProgress.Target <= goalTargetProgress.Progress { + totalTargetsCompleted++ + } + } + + if totalTargets <= totalTargetsCompleted { + userGoal := UserGoal{ + Id: userCurrentGoalStatus.UserGoalId, + Status: GoalStatusCompleted, + } + _, err = s.goalRepository.UpdateUserGoalStatus(ctx, nil, repository.UserGoal(userGoal)) + if err != nil { + slog.Error("error updating user goal status to complete", "error", err) + return err + } + + _, err = s.badgeService.HandleBadgeCreation(ctx, userId, userCurrentGoalStatus.Level) + if err != nil { + slog.Error("error creating badge", "error", err) + return err + } + } + + return nil +} + +func (s *service) UpdateUserGoalStatusMonthly(ctx context.Context) error { + userGoals, err := s.goalRepository.FetchInProgressUserGoalsOfPreviousMonth(ctx, nil) + if err != nil { + slog.Error("error getting user goals for previous month", "error", err) + return err + } + + for _, userGoal := range userGoals { + userUpdatedGoal := UserGoal{ + Id: userGoal.Id, + Status: GoalStatusCompleted, + } + _, err = s.goalRepository.UpdateUserGoalStatus(ctx, nil, repository.UserGoal(userUpdatedGoal)) + if err != nil { + slog.Error("error updating users goal for previous month", "error", err) + return err + } + } + + return nil +} + +func (s *service) CreateUserGoalSummary(ctx context.Context, userId int) (GoalSummary, error) { + userIncompleteGoalCount, err := s.goalRepository.CalculateUserIncompleteGoalsUntilDay(ctx, nil, userId) + if err != nil { + slog.Error("error calculating user incomplete goal status until day", "error", err) + return GoalSummary{}, err + } + + userCurrentGoalStatus, err := s.GetUserCurrentGoalStatus(ctx, userId) + if err != nil { + if errors.Is(err, apperrors.ErrUserGoalNotFound) { + slog.Info("user does not have active goal for current month") + userCurrentGoalStatus = &GetUserCurrentGoalStatusResponse{ + GoalTargetProgress: []UserGoalTargetProgress{}, + } + } else { + slog.Error("error getting user current goal status", "error", err) + return GoalSummary{}, err + } + } + + var totalTargetSet int + var totalTargetCompleted int + for _, s := range userCurrentGoalStatus.GoalTargetProgress { + totalTargetSet += s.Target + totalTargetCompleted += s.Progress + } + + userMonthlyGoalSummary := GoalSummary{ + UserId: userId, + SnapshotDate: time.Now().UTC(), + IncompleteGoalsCount: userIncompleteGoalCount, + TargetSet: totalTargetSet, + TargetCompleted: totalTargetCompleted, + } + + userGoalSummaryForToday, err := s.goalRepository.GetUserGoalSummaryBySnapshotDate(ctx, nil, userMonthlyGoalSummary.SnapshotDate, userId) + if err != nil { + slog.Error("error fetching user goal summary for today", "error", err) + return GoalSummary{}, err + } + + var userGoalSummary GoalSummary + if userGoalSummaryForToday == nil { + createdUserGoalSummary, err := s.goalRepository.CreateUserGoalSummary(ctx, nil, repository.GoalSummary(userMonthlyGoalSummary)) + if err != nil { + slog.Error("error creating user goal summary", "error", err) + return GoalSummary{}, err + } + userGoalSummary = GoalSummary(createdUserGoalSummary) + } else { + updatedUserGoalSummary, err := s.goalRepository.UpdateUserGoalSummary(ctx, nil, userGoalSummaryForToday.Id, repository.GoalSummary(userMonthlyGoalSummary)) + if err != nil { + slog.Error("error updating user goal summary", "error", err) + return GoalSummary{}, err + } + userGoalSummary = GoalSummary(updatedUserGoalSummary) + } + + return userGoalSummary, nil +} + +func (s *service) FetchUserGoalSummary(ctx context.Context, userId int) ([]GoalSummary, error) { + usersGoalSummary, err := s.goalRepository.FetchUserGoalSummary(ctx, nil, userId) + if err != nil { + slog.Error("error fetching user goal summary", "error", err) + return nil, err + } + + serviceUserGoalSummary := make([]GoalSummary, len(usersGoalSummary)) + for i, userGoalSummary := range usersGoalSummary { + serviceUserGoalSummary[i] = GoalSummary(userGoalSummary) + } + + return serviceUserGoalSummary, nil +} diff --git a/backend/internal/app/repository/domain.go b/backend/internal/app/repository/domain.go new file mode 100644 index 00000000..e9d86e30 --- /dev/null +++ b/backend/internal/app/repository/domain.go @@ -0,0 +1,61 @@ +package repository + +import "time" + +type Repository struct { + Id int `json:"id"` + GithubRepoId int `json:"githubRepoId"` + RepoName string `json:"repoName"` + Description string `json:"description"` + LanguagesUrl string `json:"languagesUrl"` + RepoUrl string `json:"repoUrl"` + OwnerName string `json:"ownerName"` + UpdateDate time.Time `json:"updateDate"` + ContributorsUrl string `json:"contributorsUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RepoLanguages map[string]int + +type FetchUsersContributedReposResponse struct { + Repository + Languages []string `json:"languages"` + TotalCoinsEarned int `json:"totalCoinsEarned"` +} + +type FetchParticularRepoDetailsResponse struct { + Repository + Languages []string `json:"languages"` +} + +type ContributionResponse struct { + ID string `bigquery:"id" json:"id"` + Type string `bigquery:"type" json:"type"` + ActorID int `bigquery:"actor_id" json:"actorId"` + ActorLogin string `bigquery:"actor_login" json:"actorLogin"` + RepoID int `bigquery:"repo_id" json:"repoId"` + RepoName string `bigquery:"repo_name" json:"repoName"` + RepoUrl string `bigquery:"repo_url" json:"repoUrl"` + Payload string `bigquery:"payload" json:"payload"` + CreatedAt time.Time `bigquery:"created_at" json:"createdAt"` +} + +type Contribution struct { + Id int `json:"id"` + UserId int `json:"userId"` + RepositoryId int `json:"repositoryId"` + ContributionScoreId int `json:"contributionScoreId"` + ContributionType string `json:"contributionType"` + BalanceChange int `json:"balanceChange"` + ContributedAt time.Time `json:"contributedAt"` + GithubEventId string `json:"githubEventId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type LanguagePercent struct { + Name string `json:"name"` + Bytes int `json:"bytes"` + Percentage float64 `json:"percentage"` +} diff --git a/backend/internal/app/repository/handler.go b/backend/internal/app/repository/handler.go new file mode 100644 index 00000000..58606146 --- /dev/null +++ b/backend/internal/app/repository/handler.go @@ -0,0 +1,181 @@ +package repository + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +type handler struct { + repositoryService Service + githubService github.Service +} + +type Handler interface { + FetchUsersContributedRepos(w http.ResponseWriter, r *http.Request) + FetchParticularRepoDetails(w http.ResponseWriter, r *http.Request) + FetchUserContributionsInRepo(w http.ResponseWriter, r *http.Request) + FetchLanguagePercentInRepo(w http.ResponseWriter, r *http.Request) + FetchParticularRepoContributors(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(repositoryService Service, githubService github.Service) Handler { + return &handler{ + repositoryService: repositoryService, + githubService: githubService, + } +} + +func (h *handler) FetchUsersContributedRepos(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + client := &http.Client{} + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + usersContributedRepos, err := h.repositoryService.FetchUsersContributedRepos(ctx, client, userId) + if err != nil { + slog.Error("error fetching users conributed repos", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users contributed repositories fetched successfully", usersContributedRepos) +} + +func (h *handler) FetchParticularRepoDetails(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.FetchParticularRepoDetails(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "repository details fetched successfully", repoDetails) +} + +func (h *handler) FetchParticularRepoContributors(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoContributors, err := h.githubService.FetchRepositoryContributors(ctx, repoDetails.ContributorsUrl) + if err != nil { + slog.Error("error fetching repo contributors", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "contributors for repo fetched successfully", repoContributors) +} + +func (h *handler) FetchUserContributionsInRepo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + usersContributionsInRepo, err := h.repositoryService.FetchUserContributionsInRepo(ctx, userId, repoId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users contribution for repository fetched successfully", usersContributionsInRepo) +} + +func (h *handler) FetchLanguagePercentInRepo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + repoIdPath := r.PathValue("repo_id") + repoId, err := strconv.Atoi(repoIdPath) + if err != nil { + slog.Error("error getting repo id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoDetails, err := h.repositoryService.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error fetching particular repo details", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + repoLanguages, err := h.githubService.FetchRepositoryLanguages(ctx, repoDetails.LanguagesUrl) + if err != nil { + slog.Error("error fetching particular repo languages", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + langPercent, err := h.repositoryService.CalculateLanguagePercentInRepo(ctx, RepoLanguages(repoLanguages)) + if err != nil { + slog.Error("error fetching particular repo languages", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "language percentages for repo fetched successfully", langPercent) +} diff --git a/backend/internal/app/repository/service.go b/backend/internal/app/repository/service.go new file mode 100644 index 00000000..9f2afc31 --- /dev/null +++ b/backend/internal/app/repository/service.go @@ -0,0 +1,205 @@ +package repository + +import ( + "context" + "log/slog" + "math" + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/github" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + repositoryRepository repository.RepositoryRepository + githubService github.Service +} + +type Service interface { + GetRepoByGithubId(ctx context.Context, githubRepoId int) (Repository, error) + GetRepoByRepoId(ctx context.Context, repoId int) (Repository, error) + CreateRepository(ctx context.Context, repoGithubId int, ContributionRepoDetailsUrl string) (Repository, error) + HandleRepositoryCreation(ctx context.Context, contribution ContributionResponse) (Repository, error) + FetchUsersContributedRepos(ctx context.Context, client *http.Client, userId int) ([]FetchUsersContributedReposResponse, error) + FetchParticularRepoDetails(ctx context.Context, repoId int) (FetchParticularRepoDetailsResponse, error) + FetchUserContributionsInRepo(ctx context.Context, userId int, githubRepoId int) ([]Contribution, error) + CalculateLanguagePercentInRepo(ctx context.Context, repoLanguages RepoLanguages) ([]LanguagePercent, error) + FetchUserContributedReposCount(ctx context.Context, userId int) (int, error) +} + +func NewService(repositoryRepository repository.RepositoryRepository, githubService github.Service) Service { + return &service{ + repositoryRepository: repositoryRepository, + githubService: githubService, + } +} + +func (s *service) GetRepoByGithubId(ctx context.Context, repoGithubId int) (Repository, error) { + repoDetails, err := s.repositoryRepository.GetRepoByGithubId(ctx, nil, repoGithubId) + if err != nil { + slog.Error("failed to get repository by repo github id", "error", err) + return Repository{}, err + } + + return Repository(repoDetails), nil +} + +func (s *service) GetRepoByRepoId(ctx context.Context, repobId int) (Repository, error) { + repoDetails, err := s.repositoryRepository.GetRepoByRepoId(ctx, nil, repobId) + if err != nil { + slog.Error("failed to get repository by repo id", "error", err) + return Repository{}, err + } + + return Repository(repoDetails), nil +} + +func (s *service) CreateRepository(ctx context.Context, repoGithubId int, ContributionRepoDetailsUrl string) (Repository, error) { + repo, err := s.githubService.FetchRepositoryDetails(ctx, ContributionRepoDetailsUrl) + if err != nil { + slog.Error("error fetching user repositories details", "error", err) + return Repository{}, err + } + + createRepo := Repository{ + GithubRepoId: repoGithubId, + RepoName: repo.Name, + RepoUrl: repo.RepoUrl, + Description: repo.Description, + LanguagesUrl: repo.LanguagesURL, + OwnerName: repo.RepoOwnerName.Login, + UpdateDate: repo.UpdateDate, + ContributorsUrl: repo.ContributorsUrl, + } + repositoryCreated, err := s.repositoryRepository.CreateRepository(ctx, nil, repository.Repository(createRepo)) + if err != nil { + slog.Error("failed to create repository", "error", err) + return Repository{}, err + } + + return Repository(repositoryCreated), nil +} + +func (s *service) HandleRepositoryCreation(ctx context.Context, contribution ContributionResponse) (Repository, error) { + obtainedRepository, err := s.GetRepoByGithubId(ctx, contribution.RepoID) + if err != nil { + if err == apperrors.ErrRepoNotFound { + obtainedRepository, err = s.CreateRepository(ctx, contribution.RepoID, contribution.RepoUrl) + if err != nil { + slog.Error("error creating repository", "error", err) + return Repository{}, err + } + } else { + slog.Error("error fetching repo by repo id", "error", err) + return Repository{}, err + } + } + + return obtainedRepository, nil +} + +func (s *service) FetchUsersContributedRepos(ctx context.Context, client *http.Client, userId int) ([]FetchUsersContributedReposResponse, error) { + usersContributedRepos, err := s.repositoryRepository.FetchUsersContributedRepos(ctx, nil, userId) + if err != nil { + slog.Error("error fetching users conributed repos", "error", err) + return nil, err + } + + fetchUsersContributedReposResponse := make([]FetchUsersContributedReposResponse, len(usersContributedRepos)) + + for i, usersContributedRepo := range usersContributedRepos { + fetchUsersContributedReposResponse[i].Repository = Repository(usersContributedRepo) + + contributedRepoLanguages, err := s.githubService.FetchRepositoryLanguages(ctx, usersContributedRepo.LanguagesUrl) + if err != nil { + slog.Error("error fetching languages for repository", "error", err) + return nil, err + } + + for language := range contributedRepoLanguages { + fetchUsersContributedReposResponse[i].Languages = append(fetchUsersContributedReposResponse[i].Languages, language) + } + + userRepoTotalCoins, err := s.repositoryRepository.GetUserRepoTotalCoins(ctx, nil, userId, usersContributedRepo.Id) + if err != nil { + slog.Error("error calculating total coins earned by user for the repository", "error", err) + return nil, err + } + + fetchUsersContributedReposResponse[i].TotalCoinsEarned = userRepoTotalCoins + } + + return fetchUsersContributedReposResponse, nil +} + +func (s *service) FetchParticularRepoDetails(ctx context.Context, repoId int) (FetchParticularRepoDetailsResponse, error) { + repoDetails, err := s.GetRepoByRepoId(ctx, repoId) + if err != nil { + slog.Error("error getting repo by repo id", "error", err) + return FetchParticularRepoDetailsResponse{}, err + } + + repoLanguages, err := s.githubService.FetchRepositoryLanguages(ctx, repoDetails.LanguagesUrl) + if err != nil { + slog.Error("error fetching languages for repository", "error", err) + return FetchParticularRepoDetailsResponse{}, err + } + + var particularRepoLanguages []string + for language := range repoLanguages { + particularRepoLanguages = append(particularRepoLanguages, language) + } + + particularRepoDetails := FetchParticularRepoDetailsResponse{ + Repository: repoDetails, + Languages: particularRepoLanguages, + } + + return particularRepoDetails, nil +} + +func (s *service) FetchUserContributionsInRepo(ctx context.Context, userId int, githubRepoId int) ([]Contribution, error) { + userContributionsInRepo, err := s.repositoryRepository.FetchUserContributionsInRepo(ctx, nil, userId, githubRepoId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + return nil, err + } + + serviceUserContributionsInRepo := make([]Contribution, len(userContributionsInRepo)) + for i, c := range userContributionsInRepo { + serviceUserContributionsInRepo[i] = Contribution(c) + } + + return serviceUserContributionsInRepo, nil +} + +func (s *service) CalculateLanguagePercentInRepo(ctx context.Context, repoLanguages RepoLanguages) ([]LanguagePercent, error) { + var total int + for _, bytes := range repoLanguages { + total += bytes + } + + var langPercent []LanguagePercent + + for lang, bytes := range repoLanguages { + percentage := (float64(bytes) / float64(total)) * 100 + langPercent = append(langPercent, LanguagePercent{ + Name: lang, + Bytes: bytes, + Percentage: math.Round(percentage*10) / 10, + }) + } + + return langPercent, nil +} + +func (s *service) FetchUserContributedReposCount(ctx context.Context, userId int) (int, error) { + userContributedReposCount, err := s.repositoryRepository.FetchUserContributedReposCount(ctx, nil, userId) + if err != nil { + slog.Error("error fetching users contributes repos count", "error", err) + return 0, err + } + + return userContributedReposCount, nil +} diff --git a/backend/internal/app/router.go b/backend/internal/app/router.go new file mode 100644 index 00000000..a2779a48 --- /dev/null +++ b/backend/internal/app/router.go @@ -0,0 +1,52 @@ +package app + +import ( + "net/http" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +func NewRouter(deps Dependencies) http.Handler { + router := http.NewServeMux() + + router.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { + response.WriteJson(w, http.StatusOK, "Server is up and running..", nil) + }) + + router.HandleFunc("GET /api/v1/auth/github", deps.AuthHandler.GithubOAuthLoginUrl) + router.HandleFunc("GET /api/v1/auth/github/callback", deps.AuthHandler.GithubOAuthLoginCallback) + router.HandleFunc("GET /api/v1/auth/user", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.AuthHandler.GetLoggedInUser), deps.AppCfg)) + + router.HandleFunc("PATCH /api/v1/user/email", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.UserHandler.UpdateUserEmail), deps.AppCfg)) + router.HandleFunc("DELETE /api/v1/user/delete/{user_id}", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.UserHandler.SoftDeleteUser), deps.AppCfg)) + + router.HandleFunc("GET /api/v1/user/contributions/all", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.ContributionHandler.FetchUserContributions), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/overview", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.ContributionHandler.ListMonthlyContributionSummary), deps.AppCfg)) + router.HandleFunc("GET /api/v1/contributions/types", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.ContributionHandler.ListAllContributionTypes), deps.AppCfg)) + + router.HandleFunc("GET /api/v1/user/repositories", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.RepositoryHandler.FetchUsersContributedRepos), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/repositories/{repo_id}", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.RepositoryHandler.FetchParticularRepoDetails), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/repositories/contributions/recent/{repo_id}", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.RepositoryHandler.FetchUserContributionsInRepo), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/repositories/languages/{repo_id}", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.RepositoryHandler.FetchLanguagePercentInRepo), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/repositories/contributors/{repo_id}", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.RepositoryHandler.FetchParticularRepoContributors), deps.AppCfg)) + + router.HandleFunc("GET /api/v1/leaderboard", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.UserHandler.ListUserRanks), deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/leaderboard", middleware.Authentication(middleware.AuthorizeUnblockedUser(deps.UserHandler.GetCurrentUserRank), deps.AppCfg)) + + router.HandleFunc("GET /api/v1/goal/level", middleware.Authentication(deps.GoalHandler.ListGoalLevels, deps.AppCfg)) + router.HandleFunc("POST /api/v1/goal/level/targets", middleware.Authentication(deps.GoalHandler.FetchGoalLevelTargetByGoalLevel, deps.AppCfg)) + router.HandleFunc("POST /api/v1/user/goal/level", middleware.Authentication(deps.GoalHandler.CreateUserGoalInProgress, deps.AppCfg)) + router.HandleFunc("POST /api/v1/user/goal/level/reset", middleware.Authentication(deps.GoalHandler.ResetUserCurrentGoalStatus, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/goal/level", middleware.Authentication(deps.GoalHandler.GetUserCurrentGoalStatus, deps.AppCfg)) + router.HandleFunc("GET /api/v1/user/goal/summary", middleware.Authentication(deps.GoalHandler.FetchUserMonthlyGoalSummary, deps.AppCfg)) + + router.HandleFunc("GET /api/v1/user/badges", middleware.Authentication(deps.BadgeHandler.GetBadgeDetailsOfUser, deps.AppCfg)) + + router.HandleFunc("POST /api/v1/auth/admin", deps.AuthHandler.LoginAdmin) + router.HandleFunc("PATCH /api/v1/contributions/scores/configure", middleware.Authentication(middleware.AuthorizeAdmin(deps.ContributionHandler.ConfigureContributionTypeScore), deps.AppCfg)) + router.HandleFunc("GET /api/v1/users", middleware.Authentication(middleware.AuthorizeAdmin(deps.UserHandler.ListAllUsers), deps.AppCfg)) + router.HandleFunc("PATCH /api/v1/users/{user_id}", middleware.Authentication(middleware.AuthorizeAdmin(deps.UserHandler.BlockOrUnblockUser), deps.AppCfg)) + + return middleware.CorsMiddleware(router, deps.AppCfg) +} diff --git a/backend/internal/app/transaction/domain.go b/backend/internal/app/transaction/domain.go new file mode 100644 index 00000000..fa5a2055 --- /dev/null +++ b/backend/internal/app/transaction/domain.go @@ -0,0 +1,28 @@ +package transaction + +import "time" + +type Transaction struct { + Id int `json:"id"` + UserId int `json:"userId"` + ContributionId int `json:"contributionId"` + IsRedeemed bool `json:"isRedeemed"` + IsGained bool `json:"isGained"` + TransactedBalance int `json:"transactedBalance"` + TransactedAt time.Time `json:"transactedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Contribution struct { + Id int `json:"id"` + UserId int `json:"userId"` + RepositoryId int `json:"repositoryId"` + ContributionScoreId int `json:"contributionScoreId"` + ContributionType string `json:"contributionType"` + BalanceChange int `json:"balanceChange"` + ContributedAt time.Time `json:"contributedAt"` + GithubEventId string `json:"githubEventId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/app/transaction/service.go b/backend/internal/app/transaction/service.go new file mode 100644 index 00000000..3fa01470 --- /dev/null +++ b/backend/internal/app/transaction/service.go @@ -0,0 +1,107 @@ +package transaction + +import ( + "context" + "log/slog" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/user" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + transactionRepository repository.TransactionRepository + userService user.Service +} + +type Service interface { + CreateTransaction(ctx context.Context, transactionInfo Transaction) (Transaction, error) + GetTransactionByContributionId(ctx context.Context, contributionId int) (Transaction, error) + CreateTransactionForContribution(ctx context.Context, contribution Contribution) (Transaction, error) + HandleTransactionCreation(ctx context.Context, contribution Contribution) (Transaction, error) +} + +func NewService(transactionRepository repository.TransactionRepository, userService user.Service) Service { + return &service{ + transactionRepository: transactionRepository, + userService: userService, + } +} + +func (s *service) CreateTransaction(ctx context.Context, transactionInfo Transaction) (Transaction, error) { + tx, err := s.transactionRepository.BeginTx(ctx) + if err != nil { + slog.Error("failed to start transaction creation") + return Transaction{}, err + } + + ctx = middleware.EmbedTxInContext(ctx, tx) + + defer func() { + if txErr := s.transactionRepository.HandleTransaction(ctx, tx, err); txErr != nil { + slog.Error("failed to handle transaction", "error", txErr) + err = txErr + } + }() + + transaction, err := s.transactionRepository.CreateTransaction(ctx, tx, repository.Transaction(transactionInfo)) + if err != nil { + slog.Error("error occured while creating transaction", "error", err) + return Transaction{}, err + } + + err = s.userService.UpdateUserCurrentBalance(ctx, user.Transaction(transaction)) + if err != nil { + slog.Error("error occured while updating user current balance", "error", err) + return Transaction{}, err + } + + return Transaction(transaction), nil +} + +func (s *service) GetTransactionByContributionId(ctx context.Context, contributionId int) (Transaction, error) { + transaction, err := s.transactionRepository.GetTransactionByContributionId(ctx, nil, contributionId) + if err != nil { + slog.Error("error fetching transaction using contribution id", "error", err) + return Transaction{}, err + } + + return Transaction(transaction), nil +} + +func (s *service) CreateTransactionForContribution(ctx context.Context, contribution Contribution) (Transaction, error) { + transactionInfo := Transaction{ + UserId: contribution.UserId, + ContributionId: contribution.Id, + IsRedeemed: false, + IsGained: true, + TransactedBalance: contribution.BalanceChange, + TransactedAt: contribution.ContributedAt, + } + transaction, err := s.CreateTransaction(ctx, transactionInfo) + if err != nil { + slog.Error("error creating transaction for current contribution", "error", err) + return Transaction{}, err + } + + return transaction, nil +} + +func (s *service) HandleTransactionCreation(ctx context.Context, contribution Contribution) (Transaction, error) { + transaction, err := s.GetTransactionByContributionId(ctx, contribution.Id) + if err != nil { + if err == apperrors.ErrTransactionNotFound { + transaction, err = s.CreateTransactionForContribution(ctx, contribution) + if err != nil { + slog.Error("error creating transaction for exisiting contribution", "error", err) + return Transaction{}, err + } + } else { + slog.Error("error fetching transaction", "error", err) + return Transaction{}, err + } + } + + return transaction, nil +} diff --git a/backend/internal/app/user/domain.go b/backend/internal/app/user/domain.go new file mode 100644 index 00000000..5e9a696f --- /dev/null +++ b/backend/internal/app/user/domain.go @@ -0,0 +1,70 @@ +package user + +import ( + "database/sql" + "time" +) + +type User struct { + Id int `json:"userId"` + GithubId int `json:"githubId"` + GithubUsername string `json:"githubUsername"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + CurrentBalance int `json:"currentBalance"` + CurrentActiveGoalId sql.NullInt64 `json:"currentActiveGoalId"` + IsBlocked bool `json:"isBlocked"` + IsAdmin bool `json:"isAdmin"` + Password string `json:"password"` + IsDeleted bool `json:"isDeleted"` + DeletedAt sql.NullTime `json:"deletedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreateUserRequestBody struct { + GithubId int `json:"githubId"` + GithubUsername string `json:"githubUsername"` + AvatarUrl string `json:"avatarUrl"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + IsBlocked bool `json:"isBlocked"` +} + +type Email struct { + Email string `json:"email"` +} + +type Transaction struct { + Id int `json:"id"` + UserId int `json:"userId"` + ContributionId int `json:"contributionId"` + IsRedeemed bool `json:"isRedeemed"` + IsGained bool `json:"isGained"` + TransactedBalance int `json:"transactedBalance"` + TransactedAt time.Time `json:"transactedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type LeaderboardUser struct { + Id int `json:"id"` + GithubUsername string `json:"githubUsername"` + AvatarUrl string `json:"avatarUrl"` + ContributedReposCount int `json:"contributedReposCount"` + CurrentBalance int `json:"currentBalance"` + Rank int `json:"rank"` +} + +type GoalLevel struct { + Level string `json:"level"` +} + +type AdminLoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type BlockOrUnblockUserRequest struct { + Block bool `json:"block"` +} diff --git a/backend/internal/app/user/handler.go b/backend/internal/app/user/handler.go new file mode 100644 index 00000000..eda941cf --- /dev/null +++ b/backend/internal/app/user/handler.go @@ -0,0 +1,212 @@ +package user + +import ( + "encoding/json" + "log/slog" + "net/http" + "strconv" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" +) + +type handler struct { + userService Service +} + +type Handler interface { + UpdateUserEmail(w http.ResponseWriter, r *http.Request) + SoftDeleteUser(w http.ResponseWriter, r *http.Request) + ListUserRanks(w http.ResponseWriter, r *http.Request) + GetCurrentUserRank(w http.ResponseWriter, r *http.Request) + ListAllUsers(w http.ResponseWriter, r *http.Request) + BlockOrUnblockUser(w http.ResponseWriter, r *http.Request) +} + +func NewHandler(userService Service) Handler { + return &handler{ + userService: userService, + } +} + +func (h *handler) UpdateUserEmail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + var requestBody Email + err := json.NewDecoder(r.Body).Decode(&requestBody) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + err = h.userService.UpdateUserEmail(ctx, userId, requestBody.Email) + if err != nil { + slog.Error("failed to update user email", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "email updated successfully", nil) +} + +func (h *handler) SoftDeleteUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + err := h.userService.SoftDeleteUser(ctx, userId) + if err != nil { + slog.Error("failed to softdelete user", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user scheduled for deletion", nil) +} + +func (h *handler) ListUserRanks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + leaderboard, err := h.userService.GetAllUsersRank(ctx) + if err != nil { + slog.Error("failed to get all users rank", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "leaderboard fetched successfully", leaderboard) +} + +func (h *handler) GetCurrentUserRank(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdValue := ctx.Value(middleware.UserIdKey) + userId, ok := userIdValue.(int) + if !ok { + slog.Error("error obtaining user id from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + isAdminValue := ctx.Value(middleware.IsAdminKey) + isAdmin, ok := isAdminValue.(bool) + if !ok { + slog.Error("error obtaining id admin from context") + status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) + response.WriteJson(w, status, errorMessage, nil) + return + } + + if isAdmin { + response.WriteJson(w, http.StatusOK, "current user is admin", nil) + return + } + + currentUserRank, err := h.userService.GetCurrentUserRank(ctx, userId) + if err != nil { + slog.Error("failed to get current user rank", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "current user rank fetched successfully", currentUserRank) +} + +// func (h *handler) UpdateCurrentActiveGoalId(w http.ResponseWriter, r *http.Request) { +// ctx := r.Context() + +// userIdCtxVal := ctx.Value(middleware.UserIdKey) +// userId, ok := userIdCtxVal.(int) +// if !ok { +// slog.Error("error obtaining user id from context") +// status, errorMessage := apperrors.MapError(apperrors.ErrContextValue) +// response.WriteJson(w, status, errorMessage, nil) +// return +// } + +// var goal GoalLevel +// err := json.NewDecoder(r.Body).Decode(&goal) +// if err != nil { +// slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) +// response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) +// return +// } + +// goalId, err := h.userService.UpdateCurrentActiveGoalId(ctx, userId, goal.Level) +// if err != nil { +// slog.Error("failed to update current active goal id", "error", err) +// status, errMsg := apperrors.MapError(err) +// response.WriteJson(w, status, errMsg, nil) +// return +// } + +// response.WriteJson(w, http.StatusOK, "Goal updated successfully", goalId) +// } + +func (h *handler) ListAllUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + users, err := h.userService.ListAllUsers(ctx) + if err != nil { + slog.Error("failed to fetch all users", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + response.WriteJson(w, http.StatusOK, "users fetched successfully", users) +} + +func (h *handler) BlockOrUnblockUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + userIdPath := r.PathValue("user_id") + userId, err := strconv.Atoi(userIdPath) + if err != nil { + slog.Error("error getting user id from request url", "error", err) + status, errorMessage := apperrors.MapError(err) + response.WriteJson(w, status, errorMessage, nil) + return + } + + var status BlockOrUnblockUserRequest + err = json.NewDecoder(r.Body).Decode(&status) + if err != nil { + slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) + response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) + return + } + + err = h.userService.BlockOrUnblockUser(ctx, userId, status.Block) + if err != nil { + slog.Error("failed to block/unblock user", "error", err) + status, message := apperrors.MapError(err) + response.WriteJson(w, status, message, nil) + return + } + + response.WriteJson(w, http.StatusOK, "user status updated successfully", nil) +} diff --git a/backend/internal/app/user/service.go b/backend/internal/app/user/service.go new file mode 100644 index 00000000..05fe19fe --- /dev/null +++ b/backend/internal/app/user/service.go @@ -0,0 +1,241 @@ +package user + +import ( + "context" + "log/slog" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/app/goal" + repoService "github.com/joshsoftware/code-curiosity-2025/internal/app/repository" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" + "github.com/joshsoftware/code-curiosity-2025/internal/repository" +) + +type service struct { + userRepository repository.UserRepository + goalService goal.Service + repositoryService repoService.Service +} + +type Service interface { + GetUserById(ctx context.Context, userId int) (User, error) + GetUserByGithubId(ctx context.Context, githubId int) (User, error) + CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) + UpdateUserEmail(ctx context.Context, userId int, email string) error + SoftDeleteUser(ctx context.Context, userId int) error + HardDeleteUsers(ctx context.Context) error + RecoverAccountInGracePeriod(ctx context.Context, userID int) error + UpdateUserCurrentBalance(ctx context.Context, transaction Transaction) error + GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) + // UpdateCurrentActiveGoalId(ctx context.Context, userId int, level string) (int, error) + GetLoggedInAdmin(ctx context.Context, adminInfo AdminLoginRequest) (User, error) + ListAllUsers(ctx context.Context) ([]User, error) + BlockOrUnblockUser(ctx context.Context, userID int, block bool) error +} + +func NewService(userRepository repository.UserRepository, goalService goal.Service, repositoryService repoService.Service) Service { + return &service{ + userRepository: userRepository, + goalService: goalService, + repositoryService: repositoryService, + } +} + +func (s *service) GetUserById(ctx context.Context, userId int) (User, error) { + userInfo, err := s.userRepository.GetUserById(ctx, nil, userId) + if err != nil { + slog.Error("failed to get user by id", "error", err) + return User{}, err + } + + return User(userInfo), nil + +} + +func (s *service) GetUserByGithubId(ctx context.Context, githubId int) (User, error) { + userInfo, err := s.userRepository.GetUserByGithubId(ctx, nil, githubId) + if err != nil { + slog.Error("failed to get user by github id", "error", err) + return User{}, err + } + + return User(userInfo), nil +} + +func (s *service) CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) { + user, err := s.userRepository.CreateUser(ctx, nil, repository.CreateUserRequestBody(userInfo)) + if err != nil { + slog.Error("failed to create user", "error", err) + return User{}, apperrors.ErrUserCreationFailed + } + + return User(user), nil +} + +func (s *service) UpdateUserEmail(ctx context.Context, userId int, email string) error { + err := s.userRepository.UpdateUserEmail(ctx, nil, userId, email) + if err != nil { + slog.Error("failed to update user email", "error", err) + return err + } + + return nil +} + +func (s *service) SoftDeleteUser(ctx context.Context, userID int) error { + now := time.Now() + err := s.userRepository.MarkUserAsDeleted(ctx, nil, userID, now) + if err != nil { + slog.Error("unable to softdelete user", "error", err) + return apperrors.ErrInternalServer + } + return nil +} + +func (s *service) HardDeleteUsers(ctx context.Context) error { + err := s.userRepository.HardDeleteUsers(ctx, nil) + if err != nil { + slog.Error("error deleting users that are soft deleted for more than three months", "error", err) + return err + } + + return nil +} + +func (s *service) RecoverAccountInGracePeriod(ctx context.Context, userID int) error { + err := s.userRepository.RecoverAccountInGracePeriod(ctx, nil, userID) + if err != nil { + slog.Error("failed to recover account in grace period", "error", err) + return err + } + return nil +} + +func (s *service) UpdateUserCurrentBalance(ctx context.Context, transaction Transaction) error { + user, err := s.GetUserById(ctx, transaction.UserId) + if err != nil { + slog.Error("error obtaining user by id", "error", err) + return err + } + + user.CurrentBalance += transaction.TransactedBalance + + tx, ok := middleware.ExtractTxFromContext(ctx) + if !ok { + slog.Error("error obtaining tx from context") + } + + err = s.userRepository.UpdateUserCurrentBalance(ctx, tx, repository.User(user)) + if err != nil { + slog.Error("error updating user current balance", "error", err) + return err + } + + return nil +} + +func (s *service) GetAllUsersRank(ctx context.Context) ([]LeaderboardUser, error) { + userRanks, err := s.userRepository.GetAllUsersRank(ctx, nil) + if err != nil { + slog.Error("error obtaining all users rank", "error", err) + return nil, err + } + + Leaderboard := make([]LeaderboardUser, len(userRanks)) + for i, l := range userRanks { + userContributedReposCount, err := s.repositoryService.FetchUserContributedReposCount(ctx, l.Id) + if err != nil { + slog.Error("error fetching user contributed repos count", "error", err) + return nil, err + } + + Leaderboard[i].Id = l.Id + Leaderboard[i].GithubUsername = l.GithubUsername + Leaderboard[i].ContributedReposCount = userContributedReposCount + Leaderboard[i].AvatarUrl = l.AvatarUrl + Leaderboard[i].Rank = l.Rank + Leaderboard[i].CurrentBalance = l.CurrentBalance + } + + return Leaderboard, nil +} + +func (s *service) GetCurrentUserRank(ctx context.Context, userId int) (LeaderboardUser, error) { + currentUserRank, err := s.userRepository.GetCurrentUserRank(ctx, nil, userId) + if err != nil { + slog.Error("error obtaining current user rank", "error", err) + return LeaderboardUser{}, err + } + + currentUserContributedReposCount, err := s.repositoryService.FetchUserContributedReposCount(ctx, userId) + if err != nil { + slog.Error("error fetching user contributed repos count", "error", err) + return LeaderboardUser{}, err + } + + leaderboardUser := LeaderboardUser{ + Id: currentUserRank.Id, + GithubUsername: currentUserRank.GithubUsername, + AvatarUrl: currentUserRank.AvatarUrl, + ContributedReposCount: currentUserContributedReposCount, + CurrentBalance: currentUserRank.CurrentBalance, + Rank: currentUserRank.Rank, + } + return leaderboardUser, nil +} + +// func (s *service) UpdateCurrentActiveGoalId(ctx context.Context, userId int, level string) (int, error) { + +// goalId, err := s.goalService.GetGoalIdByGoalLevel(ctx, level) + +// if err != nil { +// slog.Error("error occured while fetching goal id by goal level") +// return 0, err +// } + +// goalId, err = s.userRepository.UpdateCurrentActiveGoalId(ctx, nil, userId, goalId) + +// if err != nil { +// slog.Error("failed to update current active goal id", "error", err) +// } + +// return goalId, err +// } + +func (s *service) GetLoggedInAdmin(ctx context.Context, adminInfo AdminLoginRequest) (User, error) { + admin, err := s.userRepository.GetAdminByCredentials(ctx, nil, repository.AdminLoginRequest(adminInfo)) + if err != nil { + slog.Error("failed to verify admin credentials", "error", err) + return User{}, err + } + + return User(admin), nil +} + +func (s *service) ListAllUsers(ctx context.Context) ([]User, error) { + users, err := s.userRepository.GetAllUsers(ctx, nil) + if err != nil { + slog.Error("failed to fetch all users", "error", err) + return nil, apperrors.ErrInternalServer + } + + serviceUsers := make([]User, len(users)) + + for i, u := range users { + serviceUsers[i] = User(u) + } + + return serviceUsers, nil +} + +func (s *service) BlockOrUnblockUser(ctx context.Context, userID int, block bool) error { + err := s.userRepository.UpdateUserBlockStatus(ctx, nil, userID, block) + if err != nil { + slog.Error("failed to block/unblock user", "error", err) + return err + } + + return nil +} diff --git a/internal/config/app.go b/backend/internal/config/app.go similarity index 63% rename from internal/config/app.go rename to backend/internal/config/app.go index c4715f5c..2f5f704b 100644 --- a/internal/config/app.go +++ b/backend/internal/config/app.go @@ -25,13 +25,20 @@ type GithubOauth struct { RedirectURL string `yaml:"redirect_url" required:"true"` } +type BigqueryProject struct { + ProjectID string `yaml:"project_id" required:"true"` +} + type AppConfig struct { - IsProduction bool `yaml:"is_production"` - HTTPServer HTTPServer `yaml:"http_server"` - Database Database `yaml:"database"` - JWTSecret string `yaml:"jwt_secret"` - ClientURL string `yaml:"client_url"` - GithubOauth GithubOauth `yaml:"github_oauth"` + AppName string `yaml:"app_name"` + IsProduction bool `yaml:"is_production"` + HTTPServer HTTPServer `yaml:"http_server"` + Database Database `yaml:"database"` + JWTSecret string `yaml:"jwt_secret"` + ClientURL string `yaml:"client_url"` + GithubOauth GithubOauth `yaml:"github_oauth"` + BigqueryProject BigqueryProject `yaml:"bigquery_project"` + GithubPersonalAccessToken string `yaml:"github_personal_access_token"` } func LoadAppConfig() (AppConfig, error) { diff --git a/backend/internal/config/bigquery.go b/backend/internal/config/bigquery.go new file mode 100644 index 00000000..30294c5d --- /dev/null +++ b/backend/internal/config/bigquery.go @@ -0,0 +1,23 @@ +package config + +import ( + "context" + + "cloud.google.com/go/bigquery" +) + +type Bigquery struct { + Client *bigquery.Client +} + +func BigqueryInit(ctx context.Context, appCfg AppConfig) (Bigquery, error) { + client, err := bigquery.NewClient(ctx, appCfg.BigqueryProject.ProjectID) + if err != nil { + return Bigquery{}, err + } + + bigqueryInstance := Bigquery{ + Client: client, + } + return bigqueryInstance, nil +} diff --git a/internal/config/db.go b/backend/internal/config/db.go similarity index 100% rename from internal/config/db.go rename to backend/internal/config/db.go diff --git a/internal/db/migrate.go b/backend/internal/db/migrate.go similarity index 94% rename from internal/db/migrate.go rename to backend/internal/db/migrate.go index 634e51a7..25081a60 100644 --- a/internal/db/migrate.go +++ b/backend/internal/db/migrate.go @@ -1,4 +1,4 @@ -package main +package db import ( "errors" @@ -42,7 +42,7 @@ func InitMainDBMigrations(config config.AppConfig) (migration Migration, er erro return } -func (migration Migration) MigrationsUpAll(){ +func (migration Migration) MigrationsUpAll() { err := migration.m.Up() if err != nil { if err == migrate.ErrNoChange { @@ -56,7 +56,7 @@ func (migration Migration) MigrationsUpAll(){ slog.Info("Migration up completed") } -func (migration Migration) MigrationsUpWithSteps(steps int){ +func (migration Migration) MigrationsUpWithSteps(steps int) { if err := migration.m.Steps(steps); err != nil { if err == migrate.ErrNoChange { slog.Error("No new migrations to apply") @@ -65,7 +65,7 @@ func (migration Migration) MigrationsUpWithSteps(steps int){ slog.Error("An error occurred while making migrations up", "error", err) return - } + } slog.Info("Current migration version:", "version", migration.MigrationVersion()) slog.Info("Migration up completed") @@ -110,7 +110,7 @@ func (migration Migration) MigrationsDownWithSteps(steps int) { slog.Error("An error occurred while making migrations down", "error", err) return - } + } slog.Info("Current migration version:", "version", migration.MigrationVersion()) slog.Info("Migration down completed") @@ -206,13 +206,17 @@ func main() { } action := os.Args[1] + var steps string + if len(os.Args) > 2 { + steps = os.Args[2] + } switch action { case "up": - migration.MigrationsUp(os.Args[2]) + migration.MigrationsUp(steps) case "down": - migration.MigrationsDown(os.Args[2]) + migration.MigrationsDown(steps) case "create": - migration.CreateMigrationFile(os.Args[2]) + migration.CreateMigrationFile(steps) default: slog.Info("Invalid action. Use 'up' or 'down' or 'create'.") } diff --git a/internal/db/migrations/1748862201_init.down.sql b/backend/internal/db/migrations/1748862201_init.down.sql similarity index 100% rename from internal/db/migrations/1748862201_init.down.sql rename to backend/internal/db/migrations/1748862201_init.down.sql index 5419a1b9..fa375e10 100644 --- a/internal/db/migrations/1748862201_init.down.sql +++ b/backend/internal/db/migrations/1748862201_init.down.sql @@ -1,5 +1,4 @@ DROP TABLE IF EXISTS "goal_contribution"; -DROP TABLE IF EXISTS "goal"; DROP TABLE IF EXISTS "summary"; DROP TABLE IF EXISTS "badges"; DROP TABLE IF EXISTS "leaderboard_hourly"; @@ -8,3 +7,4 @@ DROP TABLE IF EXISTS "contributions"; DROP TABLE IF EXISTS "repositories"; DROP TABLE IF EXISTS "contribution_score"; DROP TABLE IF EXISTS "users"; +DROP TABLE IF EXISTS "goal"; diff --git a/internal/db/migrations/1748862201_init.up.sql b/backend/internal/db/migrations/1748862201_init.up.sql similarity index 91% rename from internal/db/migrations/1748862201_init.up.sql rename to backend/internal/db/migrations/1748862201_init.up.sql index 476693ac..da1ebc85 100644 --- a/internal/db/migrations/1748862201_init.up.sql +++ b/backend/internal/db/migrations/1748862201_init.up.sql @@ -3,7 +3,7 @@ CREATE TABLE "users"( "github_id" BIGINT NOT NULL UNIQUE, "github_username" VARCHAR(255) NOT NULL, "avatar_url" VARCHAR(255) NOT NULL, - "email" VARCHAR(255) NULL, + "email" VARCHAR(255) NULL DEFAULT '', "current_active_goal_id" BIGINT NULL, "current_balance" BIGINT DEFAULT 0, "is_blocked" BOOLEAN DEFAULT FALSE, @@ -113,28 +113,28 @@ CREATE TABLE "goal_contribution"( ); ALTER TABLE - "goal_contribution" ADD CONSTRAINT "goal_contribution_set_by_user_id_foreign" FOREIGN KEY("set_by_user_id") REFERENCES "users"("id"); + "goal_contribution" ADD CONSTRAINT "goal_contribution_set_by_user_id_foreign" FOREIGN KEY("set_by_user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "goal_contribution" ADD CONSTRAINT "goal_contribution_contribution_score_id_foreign" FOREIGN KEY("contribution_score_id") REFERENCES "contribution_score"("id"); ALTER TABLE - "contribution_score" ADD CONSTRAINT "contribution_score_admin_id_foreign" FOREIGN KEY("admin_id") REFERENCES "users"("id"); + "contribution_score" ADD CONSTRAINT "contribution_score_admin_id_foreign" FOREIGN KEY("admin_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE - "summary" ADD CONSTRAINT "summary_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "summary" ADD CONSTRAINT "summary_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE - "transactions" ADD CONSTRAINT "transactions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "transactions" ADD CONSTRAINT "transactions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "contributions" ADD CONSTRAINT "contributions_contribution_score_id_foreign" FOREIGN KEY("contribution_score_id") REFERENCES "contribution_score"("id"); ALTER TABLE - "badges" ADD CONSTRAINT "badges_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "badges" ADD CONSTRAINT "badges_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "goal_contribution" ADD CONSTRAINT "goal_contribution_goal_id_foreign" FOREIGN KEY("goal_id") REFERENCES "goal"("id"); ALTER TABLE "transactions" ADD CONSTRAINT "transactions_contribution_id_foreign" FOREIGN KEY("contribution_id") REFERENCES "contributions"("id"); ALTER TABLE - "contributions" ADD CONSTRAINT "contributions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "contributions" ADD CONSTRAINT "contributions_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "contributions" ADD CONSTRAINT "contributions_repository_id_foreign" FOREIGN KEY("repository_id") REFERENCES "repositories"("id"); ALTER TABLE - "leaderboard_hourly" ADD CONSTRAINT "leaderboard_hourly_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id"); + "leaderboard_hourly" ADD CONSTRAINT "leaderboard_hourly_user_id_foreign" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE; ALTER TABLE "summary" ADD CONSTRAINT "summary_contribution_id_foreign" FOREIGN KEY("contribution_id") REFERENCES "contributions"("id"); \ No newline at end of file diff --git a/backend/internal/db/migrations/1750328591_add_column_contributors_url.down.sql b/backend/internal/db/migrations/1750328591_add_column_contributors_url.down.sql new file mode 100644 index 00000000..1ed07319 --- /dev/null +++ b/backend/internal/db/migrations/1750328591_add_column_contributors_url.down.sql @@ -0,0 +1 @@ +ALTER TABLE repositories DROP COLUMN contributors_url; \ No newline at end of file diff --git a/backend/internal/db/migrations/1750328591_add_column_contributors_url.up.sql b/backend/internal/db/migrations/1750328591_add_column_contributors_url.up.sql new file mode 100644 index 00000000..c05df317 --- /dev/null +++ b/backend/internal/db/migrations/1750328591_add_column_contributors_url.up.sql @@ -0,0 +1 @@ +ALTER TABLE repositories ADD COLUMN contributors_url VARCHAR(255) DEFAULT ''; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql b/backend/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql new file mode 100644 index 00000000..ff2f1332 --- /dev/null +++ b/backend/internal/db/migrations/1751016438_allow-null-contribution-id.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE transactions +ALTER COLUMN contribution_id SET NOT NULL; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751016438_allow-null-contribution-id.up.sql b/backend/internal/db/migrations/1751016438_allow-null-contribution-id.up.sql new file mode 100644 index 00000000..8f9e5d0b --- /dev/null +++ b/backend/internal/db/migrations/1751016438_allow-null-contribution-id.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE transactions +ALTER COLUMN contribution_id DROP NOT NULL; diff --git a/backend/internal/db/migrations/1751028730_add-gh-event-id.down.sql b/backend/internal/db/migrations/1751028730_add-gh-event-id.down.sql new file mode 100644 index 00000000..a63e61ff --- /dev/null +++ b/backend/internal/db/migrations/1751028730_add-gh-event-id.down.sql @@ -0,0 +1 @@ +ALTER TABLE contributions DROP COLUMN github_event_id; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751028730_add-gh-event-id.up.sql b/backend/internal/db/migrations/1751028730_add-gh-event-id.up.sql new file mode 100644 index 00000000..334b9764 --- /dev/null +++ b/backend/internal/db/migrations/1751028730_add-gh-event-id.up.sql @@ -0,0 +1 @@ +ALTER TABLE contributions ADD COLUMN github_event_id VARCHAR(255) DEFAULT ''; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.down.sql b/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.down.sql new file mode 100644 index 00000000..7645eb94 --- /dev/null +++ b/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.down.sql @@ -0,0 +1 @@ +ALTER TABLE repositories ALTER COLUMN contributors_url DROP NOT NULL; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.up.sql b/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.up.sql new file mode 100644 index 00000000..bba0f673 --- /dev/null +++ b/backend/internal/db/migrations/1751266661_set-not-null-contributors-url.up.sql @@ -0,0 +1 @@ +ALTER TABLE repositories ALTER COLUMN contributors_url SET NOT NULL; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.down.sql b/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.down.sql new file mode 100644 index 00000000..5828c2ec --- /dev/null +++ b/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.down.sql @@ -0,0 +1 @@ +ALTER TABLE contributions ALTER COLUMN github_event_id DROP NOT NULL; \ No newline at end of file diff --git a/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.up.sql b/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.up.sql new file mode 100644 index 00000000..2ac1f91e --- /dev/null +++ b/backend/internal/db/migrations/1751268286_set-not-null-gh-event-id.up.sql @@ -0,0 +1 @@ +ALTER TABLE contributions ALTER COLUMN github_event_id SET NOT NULL; \ No newline at end of file diff --git a/backend/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql b/backend/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql new file mode 100644 index 00000000..a9870134 --- /dev/null +++ b/backend/internal/db/migrations/1752476063_create-index-users-current-balance.down.sql @@ -0,0 +1 @@ +drop index idx_users_current_balance \ No newline at end of file diff --git a/backend/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql b/backend/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql new file mode 100644 index 00000000..60222f22 --- /dev/null +++ b/backend/internal/db/migrations/1752476063_create-index-users-current-balance.up.sql @@ -0,0 +1,3 @@ +CREATE INDEX idx_users_current_balance +ON users(current_balance DESC) +WHERE is_admin = false AND is_deleted = false; diff --git a/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.down.sql b/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.down.sql new file mode 100644 index 00000000..63928214 --- /dev/null +++ b/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN current_active_goal_id BIGINT DEFAULT NULL REFERENCES goal(id); diff --git a/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.up.sql b/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.up.sql new file mode 100644 index 00000000..0d82aa33 --- /dev/null +++ b/backend/internal/db/migrations/1755607858_remove-current-active-goal-id.up.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS current_active_goal_id; \ No newline at end of file diff --git a/backend/internal/db/migrations/1756407007_drop-goal-contribution.down.sql b/backend/internal/db/migrations/1756407007_drop-goal-contribution.down.sql new file mode 100644 index 00000000..472ca2ed --- /dev/null +++ b/backend/internal/db/migrations/1756407007_drop-goal-contribution.down.sql @@ -0,0 +1,16 @@ +CREATE TABLE "goal_contribution"( + "id" SERIAL PRIMARY KEY, + "goal_id" BIGINT NOT NULL, + "contribution_score_id" BIGINT NOT NULL, + "target_count" BIGINT NOT NULL, + "is_custom" BOOLEAN NOT NULL, + "set_by_user_id" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE + "goal_contribution" ADD CONSTRAINT "goal_contribution_set_by_user_id_foreign" FOREIGN KEY("set_by_user_id") REFERENCES "users"("id") ON DELETE CASCADE; +ALTER TABLE + "goal_contribution" ADD CONSTRAINT "goal_contribution_contribution_score_id_foreign" FOREIGN KEY("contribution_score_id") REFERENCES "contribution_score"("id"); +ALTER TABLE + "goal_contribution" ADD CONSTRAINT "goal_contribution_goal_id_foreign" FOREIGN KEY("goal_id") REFERENCES "goal"("id"); \ No newline at end of file diff --git a/backend/internal/db/migrations/1756407007_drop-goal-contribution.up.sql b/backend/internal/db/migrations/1756407007_drop-goal-contribution.up.sql new file mode 100644 index 00000000..be410fff --- /dev/null +++ b/backend/internal/db/migrations/1756407007_drop-goal-contribution.up.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS goal_contribution; \ No newline at end of file diff --git a/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.down.sql b/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.down.sql new file mode 100644 index 00000000..501abd0a --- /dev/null +++ b/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.down.sql @@ -0,0 +1 @@ +ALTER TABLE goal_level RENAME TO goal; \ No newline at end of file diff --git a/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.up.sql b/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.up.sql new file mode 100644 index 00000000..43ec540c --- /dev/null +++ b/backend/internal/db/migrations/1756407040_rename-goal-to-goal_level.up.sql @@ -0,0 +1 @@ +ALTER TABLE goal RENAME TO goal_level; \ No newline at end of file diff --git a/backend/internal/db/migrations/1756408433_create-table-goal-level-target.down.sql b/backend/internal/db/migrations/1756408433_create-table-goal-level-target.down.sql new file mode 100644 index 00000000..18723da3 --- /dev/null +++ b/backend/internal/db/migrations/1756408433_create-table-goal-level-target.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS goal_level_target; diff --git a/backend/internal/db/migrations/1756408433_create-table-goal-level-target.up.sql b/backend/internal/db/migrations/1756408433_create-table-goal-level-target.up.sql new file mode 100644 index 00000000..1e2d9c63 --- /dev/null +++ b/backend/internal/db/migrations/1756408433_create-table-goal-level-target.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE "goal_level_target" ( + "id" BIGSERIAL PRIMARY KEY, + "goal_level_id" BIGINT NOT NULL, + "contribution_score_id" BIGINT NOT NULL, + "target" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Foreign keys +ALTER TABLE "goal_level_target" + ADD CONSTRAINT "goal_level_target_goal_level_id_fkey" + FOREIGN KEY ("goal_level_id") REFERENCES "goal_level"("id") ON DELETE CASCADE; + +ALTER TABLE "goal_level_target" + ADD CONSTRAINT "goal_level_target_contribution_score_id_fkey" + FOREIGN KEY ("contribution_score_id") REFERENCES "contribution_score"("id"); diff --git a/backend/internal/db/migrations/1756408501_create-table-user_goal.down.sql b/backend/internal/db/migrations/1756408501_create-table-user_goal.down.sql new file mode 100644 index 00000000..06bf86aa --- /dev/null +++ b/backend/internal/db/migrations/1756408501_create-table-user_goal.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_goal; diff --git a/backend/internal/db/migrations/1756408501_create-table-user_goal.up.sql b/backend/internal/db/migrations/1756408501_create-table-user_goal.up.sql new file mode 100644 index 00000000..acd7c48c --- /dev/null +++ b/backend/internal/db/migrations/1756408501_create-table-user_goal.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE "user_goal" ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" BIGINT NOT NULL, + "goal_level_id" BIGINT, + "status" VARCHAR NOT NULL, + "month_started_at" TIMESTAMPTZ NOT NULL, + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Foreign keys +ALTER TABLE "user_goal" + ADD CONSTRAINT "user_goal_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; + +ALTER TABLE "user_goal" + ADD CONSTRAINT "user_goal_goal_level_id_fkey" + FOREIGN KEY ("goal_level_id") REFERENCES "goal_level"("id"); diff --git a/backend/internal/db/migrations/1756408866_create-table-user_goal_target.down.sql b/backend/internal/db/migrations/1756408866_create-table-user_goal_target.down.sql new file mode 100644 index 00000000..60e13212 --- /dev/null +++ b/backend/internal/db/migrations/1756408866_create-table-user_goal_target.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_goal_target; diff --git a/backend/internal/db/migrations/1756408866_create-table-user_goal_target.up.sql b/backend/internal/db/migrations/1756408866_create-table-user_goal_target.up.sql new file mode 100644 index 00000000..c5ad79b4 --- /dev/null +++ b/backend/internal/db/migrations/1756408866_create-table-user_goal_target.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE "user_goal_target" ( + "id" BIGSERIAL PRIMARY KEY, + "user_goal_id" BIGINT NOT NULL, + "contribution_score_id" BIGINT NOT NULL, + "target" BIGINT NOT NULL, + "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_goal_target_unique_user_goal_score" + UNIQUE ("user_goal_id", "contribution_score_id") +); + +-- Foreign keys +ALTER TABLE "user_goal_target" + ADD CONSTRAINT "user_goal_target_user_goal_id_fkey" + FOREIGN KEY ("user_goal_id") REFERENCES "user_goal"("id") ON DELETE CASCADE; + +ALTER TABLE "user_goal_target" + ADD CONSTRAINT "user_goal_target_contribution_score_id_fkey" + FOREIGN KEY ("contribution_score_id") REFERENCES "contribution_score"("id"); diff --git a/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.down.sql b/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.down.sql new file mode 100644 index 00000000..fa0fefec --- /dev/null +++ b/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_goal_progress; diff --git a/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.up.sql b/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.up.sql new file mode 100644 index 00000000..35ca4c99 --- /dev/null +++ b/backend/internal/db/migrations/1756409008_create-table-user_goal_progress.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE "user_goal_progress" ( + "user_goal_target_id" BIGINT NOT NULL, + "contribution_id" BIGINT NOT NULL, + + CONSTRAINT "user_goal_progress_pkey" + PRIMARY KEY ("user_goal_target_id", "contribution_id") +); + +-- Foreign keys +ALTER TABLE "user_goal_progress" + ADD CONSTRAINT "user_goal_progress_user_goal_target_id_fkey" + FOREIGN KEY ("user_goal_target_id") REFERENCES "user_goal_target"("id") ON DELETE CASCADE; + +ALTER TABLE "user_goal_progress" + ADD CONSTRAINT "user_goal_progress_contribution_id_fkey" + FOREIGN KEY ("contribution_id") REFERENCES "contributions"("id") ON DELETE CASCADE; diff --git a/backend/internal/db/migrations/1756810339_create-table-goal-summary.down.sql b/backend/internal/db/migrations/1756810339_create-table-goal-summary.down.sql new file mode 100644 index 00000000..7b8159e1 --- /dev/null +++ b/backend/internal/db/migrations/1756810339_create-table-goal-summary.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS "goal_summary"; \ No newline at end of file diff --git a/backend/internal/db/migrations/1756810339_create-table-goal-summary.up.sql b/backend/internal/db/migrations/1756810339_create-table-goal-summary.up.sql new file mode 100644 index 00000000..6bf6be75 --- /dev/null +++ b/backend/internal/db/migrations/1756810339_create-table-goal-summary.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE "goal_summary" ( + "id" BIGSERIAL PRIMARY KEY, + "user_id" BIGINT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE, + "snapshot_date" TIMESTAMPTZ NOT NULL, + "incomplete_goals_count" BIGINT NOT NULL DEFAULT 0, + "target_set" BIGINT NOT NULL DEFAULT 0, + "target_completed" BIGINT NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/backend/internal/pkg/apperrors/errors.go b/backend/internal/pkg/apperrors/errors.go new file mode 100644 index 00000000..ce6fc047 --- /dev/null +++ b/backend/internal/pkg/apperrors/errors.go @@ -0,0 +1,90 @@ +package apperrors + +import ( + "errors" + "net/http" +) + +var ( + ErrContextValue = errors.New("error obtaining value from context") + ErrInternalServer = errors.New("internal server error") + + ErrInvalidRequestBody = errors.New("invalid or missing parameters in the request body") + ErrInvalidQueryParams = errors.New("invalid or missing query parameters") + ErrFailedMarshal = errors.New("failed to parse request body") + + ErrUnauthorizedAccess = errors.New("unauthorized. please provide a valid access token") + ErrAccessForbidden = errors.New("access forbidden") + ErrUserBlocked = errors.New("blocked user") + ErrInvalidToken = errors.New("invalid or expired token") + + ErrFailedInitializingLogger = errors.New("failed to initialize logger") + ErrNoAppConfigPath = errors.New("no config path provided") + ErrFailedToLoadAppConfig = errors.New("failed to load environment configuration") + + ErrLoginWithGithubFailed = errors.New("failed to login with Github") + ErrGithubTokenExchangeFailed = errors.New("failed to exchange Github token") + ErrFailedToGetGithubUser = errors.New("failed to get Github user info") + ErrFailedToGetUserEmail = errors.New("failed to get user email from Github") + + ErrUserNotFound = errors.New("user not found") + ErrUserCreationFailed = errors.New("failed to create user") + + ErrJWTCreationFailed = errors.New("failed to create jwt token") + ErrAuthorizationFailed = errors.New("failed to authorize user") + + ErrRepoNotFound = errors.New("repository not found") + ErrRepoCreationFailed = errors.New("failed to create repo for user") + ErrCalculatingUserRepoTotalCoins = errors.New("error calculating total coins earned by user for the repository") + ErrFetchingUsersContributedRepos = errors.New("error fetching users contributed repositories") + ErrFetchingUserContributionsInRepo = errors.New("error fetching users contribution in repository") + ErrFetchingUsersContributedReposCount = errors.New("error fetching user contributed repos count") + + ErrFetchingFromBigquery = errors.New("error fetching contributions from bigquery service") + ErrNextContribution = errors.New("error while loading next bigquery contribution") + ErrContributionCreationFailed = errors.New("failed to create contrbitution") + ErrFetchingRecentContributions = errors.New("failed to fetch users five recent contributions") + ErrFetchingAllContributions = errors.New("failed to fetch all contributions for user") + ErrContributionScoreNotFound = errors.New("failed to get contributionscore details for given contribution type") + ErrFetchingContribution = errors.New("error fetching contribution by github repo id") + ErrContributionNotFound = errors.New("contribution not found") + ErrFetchingContributionTypes = errors.New("failed to fetch all contribution types") + ErrNoContributionForContributionType = errors.New("contribution for contribution type does not exist") + ErrFetchingUserContributionsForMonth = errors.New("error fetching user contributions for month") + + ErrTransactionCreationFailed = errors.New("error failed to create transaction") + ErrTransactionNotFound = errors.New("error transaction for the contribution id does not exist") + + ErrFetchingGoals = errors.New("error fetching goal levels ") + ErrGoalLevelNotFound = errors.New("error goal level does not exist") + ErrFailedToGetGoalLevel = errors.New("error failed to get goal level") + ErrUserGoalCreationFailed = errors.New("error creating user goal") + ErrFetchingGoalLevelTargets = errors.New("error fetching goal level targets") + ErrUserGoalTargetCreationFailed = errors.New("error creating user goal target") + ErrUserGoalProgressCreationFailed = errors.New("error creating user goal progress") + ErrUserGoalNotFound = errors.New("error user does not have any goal set for current month") + ErrFailedToGetUserGoal = errors.New("error failed to get goal set by user in current month") + ErrUserGoalExists = errors.New("error user already has goal set for current month") + ErrFailedResettingGoal = errors.New("error cannot reset goal after 48 hours of creating it or completeing before month") + + ErrBadgeCreationFailed = errors.New("failed to create badge for user") + + ErrInvalidCredentials = errors.New("error invalid credentials") +) + +func MapError(err error) (statusCode int, errMessage string) { + switch err { + case ErrInvalidRequestBody, ErrInvalidQueryParams, ErrContextValue: + return http.StatusBadRequest, err.Error() + case ErrUnauthorizedAccess: + return http.StatusUnauthorized, err.Error() + case ErrAccessForbidden: + return http.StatusForbidden, err.Error() + case ErrUserNotFound, ErrRepoNotFound, ErrContributionNotFound: + return http.StatusNotFound, err.Error() + case ErrInvalidToken: + return http.StatusUnprocessableEntity, err.Error() + default: + return http.StatusInternalServerError, err.Error() + } +} diff --git a/internal/pkg/jwt/jwt.go b/backend/internal/pkg/jwt/jwt.go similarity index 86% rename from internal/pkg/jwt/jwt.go rename to backend/internal/pkg/jwt/jwt.go index 1f5a936b..80a1a84f 100644 --- a/internal/pkg/jwt/jwt.go +++ b/backend/internal/pkg/jwt/jwt.go @@ -9,13 +9,15 @@ import ( type Claims struct { UserId int + IsBlocked bool IsAdmin bool jwt.RegisteredClaims } -func GenerateJWT(userId int, isAdmin bool, appCfg config.AppConfig) (string, error) { +func GenerateJWT(userId int, isAdmin bool, isBlocked bool, appCfg config.AppConfig) (string, error) { claims := Claims{ UserId: userId, + IsBlocked: isBlocked, IsAdmin: isAdmin, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), diff --git a/internal/pkg/middleware/middleware.go b/backend/internal/pkg/middleware/middleware.go similarity index 53% rename from internal/pkg/middleware/middleware.go rename to backend/internal/pkg/middleware/middleware.go index 3ece0897..9050c363 100644 --- a/internal/pkg/middleware/middleware.go +++ b/backend/internal/pkg/middleware/middleware.go @@ -5,23 +5,37 @@ import ( "net/http" "strings" + "github.com/jmoiron/sqlx" "github.com/joshsoftware/code-curiosity-2025/internal/config" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/jwt" "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" ) +type txKeyType struct{} + +var txKey = txKeyType{} + type contextKey string const ( - UserIdKey contextKey = "userId" - IsAdminKey contextKey = "isAdmin" + UserIdKey contextKey = "userId" + IsBlockedKey contextKey = "isBlocked" + IsAdminKey contextKey = "isAdmin" ) +func EmbedTxInContext(ctx context.Context, tx *sqlx.Tx) context.Context { + return context.WithValue(ctx, txKey, tx) +} + +func ExtractTxFromContext(ctx context.Context) (*sqlx.Tx, bool) { + tx, ok := ctx.Value(txKey).(*sqlx.Tx) + return tx, ok +} + func CorsMiddleware(next http.Handler, appCfg config.AppConfig) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", appCfg.ClientURL) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") @@ -53,8 +67,46 @@ func Authentication(next http.HandlerFunc, appCfg config.AppConfig) http.Handler ctx := context.WithValue(r.Context(), UserIdKey, userId) isAdmin := token.IsAdmin ctx = context.WithValue(ctx, IsAdminKey, isAdmin) + IsBlocked := token.IsBlocked + ctx = context.WithValue(ctx, IsBlockedKey, IsBlocked) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } + +func AuthorizeUnblockedUser(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + isBlocked, ok := ctx.Value(IsBlockedKey).(bool) + if !ok { + response.WriteJson(w, http.StatusInternalServerError, apperrors.ErrContextValue.Error(), nil) + return + } + + if isBlocked { + response.WriteJson(w, http.StatusForbidden, apperrors.ErrUserBlocked.Error(), nil) + return + } + + next.ServeHTTP(w, r) + }) +} + +func AuthorizeAdmin(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + isAdmin, ok := ctx.Value(IsAdminKey).(bool) + if !ok { + response.WriteJson(w, http.StatusInternalServerError, apperrors.ErrContextValue.Error(), nil) + return + } + + if !isAdmin { + response.WriteJson(w, http.StatusUnauthorized, apperrors.ErrUnauthorizedAccess.Error(), nil) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go similarity index 100% rename from internal/pkg/response/response.go rename to backend/internal/pkg/response/response.go diff --git a/backend/internal/pkg/utils/helper.go b/backend/internal/pkg/utils/helper.go new file mode 100644 index 00000000..e024eec3 --- /dev/null +++ b/backend/internal/pkg/utils/helper.go @@ -0,0 +1,79 @@ +package utils + +import ( + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +func FormatIntSliceForQuery(ids []int) string { + strIDs := make([]string, len(ids)) + for i, id := range ids { + strIDs[i] = fmt.Sprintf("%d", id) + } + + return strings.Join(strIDs, ",") +} + +func DoGet(httpClient *http.Client, url string, headers map[string]string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + slog.Error("failed to create GET request", "error", err) + return nil, err + } + + for key, value := range headers { + req.Header.Set(key, value) + } + + resp, err := httpClient.Do(req) + if err != nil { + slog.Error("failed to send GET request", "error", err) + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + slog.Error("error reading body", "error", err) + return nil, err + } + + return body, nil +} + +func ValidateYearQueryParam(yearVal string) (int, error) { + year, err := strconv.Atoi(yearVal) + if err != nil { + slog.Error("error converting year string value to int") + return 0, err + } + + if year < 2025 || year > time.Now().Year() { + slog.Error("invalid year value") + return 0, apperrors.ErrInvalidQueryParams + } + + return year, nil +} + +func ValidateMonthQueryParam(monthVal string) (int, error) { + month, err := strconv.Atoi(monthVal) + if err != nil { + slog.Error("error converting month string value to int") + return 0, err + } + + if month < 0 || month > 12 { + slog.Error("invalid month value") + return 0, apperrors.ErrInvalidQueryParams + } + + return month, nil +} diff --git a/backend/internal/repository/badge.go b/backend/internal/repository/badge.go new file mode 100644 index 00000000..b1353dbd --- /dev/null +++ b/backend/internal/repository/badge.go @@ -0,0 +1,92 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type badgeRepository struct { + BaseRepository +} + +type BadgeRepository interface { + RepositoryTransaction + GetUserCurrentMonthBadge(ctx context.Context, tx *sqlx.Tx, userId int) (Badge, error) + CreateBadge(ctx context.Context, tx *sqlx.Tx, userId int, badgeType string) (Badge, error) + GetBadgeDetailsOfUser(ctx context.Context, tx *sqlx.Tx, userId int) ([]Badge, error) +} + +func NewBadgeRepository(db *sqlx.DB) BadgeRepository { + return &badgeRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + createBadgeQuery = ` + INSERT INTO badges( + user_id, + badge_type, + earned_at + ) + VALUES($1, $2, $3) + RETURNING *` + + getBadgeDetailsOfUserQuery = "SELECT * FROM badges WHERE user_id = $1 ORDER BY earned_at DESC" + + getUserCurrentMonthBadgeQuery = ` + SELECT * FROM badges + WHERE user_id = $1 + AND earned_at >= DATE_TRUNC('month', CURRENT_DATE) + AND earned_at < DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')` +) + +func (br *badgeRepository) GetUserCurrentMonthBadge(ctx context.Context, tx *sqlx.Tx, userId int) (Badge, error) { + executer := br.BaseRepository.initiateQueryExecuter(tx) + + var badge Badge + err := executer.GetContext(ctx, &badge, getUserCurrentMonthBadgeQuery, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("badge does not exist for user", "error", err) + return Badge{}, err + } + slog.Error("error fetching current month earned badge for user", "error", err) + return Badge{}, apperrors.ErrBadgeCreationFailed + } + + return badge, nil + +} + +func (br *badgeRepository) CreateBadge(ctx context.Context, tx *sqlx.Tx, userId int, badgeType string) (Badge, error) { + executer := br.BaseRepository.initiateQueryExecuter(tx) + + var createdBadge Badge + err := executer.GetContext(ctx, &createdBadge, createBadgeQuery, userId, badgeType, time.Now()) + if err != nil { + slog.Error("error creating badge for user", "error", err) + return Badge{}, apperrors.ErrBadgeCreationFailed + } + + return createdBadge, nil +} + +func (br *badgeRepository) GetBadgeDetailsOfUser(ctx context.Context, tx *sqlx.Tx, userId int) ([]Badge, error) { + executer := br.BaseRepository.initiateQueryExecuter(tx) + + var badges []Badge + + err := executer.SelectContext(ctx, &badges, getBadgeDetailsOfUserQuery, userId) + if err != nil { + return nil, err + } + + return badges, nil +} diff --git a/internal/repository/base.go b/backend/internal/repository/base.go similarity index 88% rename from internal/repository/base.go rename to backend/internal/repository/base.go index 5516997b..e4b0795e 100644 --- a/internal/repository/base.go +++ b/backend/internal/repository/base.go @@ -23,6 +23,9 @@ type RepositoryTransaction interface { type QueryExecuter interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error + SelectContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error } func (b *BaseRepository) BeginTx(ctx context.Context) (*sqlx.Tx, error) { @@ -61,7 +64,7 @@ func (b *BaseRepository) HandleTransaction(ctx context.Context, tx *sqlx.Tx, inc } return nil } - + err := tx.Commit() if err != nil { slog.Error("error occurred while committing database transaction", "error", err) diff --git a/backend/internal/repository/contribution.go b/backend/internal/repository/contribution.go new file mode 100644 index 00000000..81dfdf8c --- /dev/null +++ b/backend/internal/repository/contribution.go @@ -0,0 +1,224 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type contributionRepository struct { + BaseRepository +} + +type ContributionRepository interface { + RepositoryTransaction + CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionDetails Contribution) (Contribution, error) + GetContributionScoreDetailsByContributionType(ctx context.Context, tx *sqlx.Tx, contributionType string) (ContributionScore, error) + FetchUserContributions(ctx context.Context, tx *sqlx.Tx, userId int) ([]Contribution, error) + GetContributionByGithubEventId(ctx context.Context, tx *sqlx.Tx, githubEventId string) (Contribution, error) + GetAllContributionTypes(ctx context.Context, tx *sqlx.Tx) ([]ContributionScore, error) + ListMonthlyContributionSummary(ctx context.Context, tx *sqlx.Tx, year int, month int, userId int) ([]MonthlyContributionSummary, error) + GetContributionTypeByContributionScoreId(ctx context.Context, tx *sqlx.Tx, contributionScoreId int) (string, error) + UpdateContributionTypeScore(ctx context.Context, tx *sqlx.Tx, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) + FetchUserContributionsForMonth(ctx context.Context, tx *sqlx.Tx, userId int, monthStartedAt time.Time) ([]Contribution, error) +} + +func NewContributionRepository(db *sqlx.DB) ContributionRepository { + return &contributionRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + createContributionQuery = ` + INSERT INTO contributions ( + user_id, + repository_id, + contribution_score_id, + contribution_type, + balance_change, + contributed_at, + github_event_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *` + + getContributionScoreDetailsByContributionTypeQuery = `SELECT * from contribution_score where contribution_type=$1` + + fetchUserContributionsQuery = `SELECT * from contributions where user_id=$1 order by contributed_at desc` + + getContributionByGithubEventIdQuery = `SELECT * from contributions where github_event_id=$1` + + getAllContributionTypesQuery = `SELECT * from contribution_score` + + getMonthlyContributionSummaryQuery = ` + SELECT + DATE_TRUNC('month', contributed_at) AS month, + contribution_type, + COUNT(*) AS contribution_count, + SUM(balance_change) AS total_coins + FROM contributions + WHERE user_id = $1 + AND DATE_TRUNC('month', contributed_at) = MAKE_DATE($2, $3, 1)::timestamptz + GROUP BY + month, contribution_type;` + + getContributionTypeByContributionScoreIdQuery = `SELECT contribution_type from contribution_score where id=$1` + + updateContributionTypeScoreQuery = "UPDATE contribution_score SET score = $1 where contribution_type = $2" + + fetchUserContributionsForMonthQuery = ` + SELECT * FROM contributions + WHERE user_id = $1 + AND contributed_at >= $2 + AND contributed_at < ($2 + INTERVAL '1 month'); + ` +) + +func (cr *contributionRepository) CreateContribution(ctx context.Context, tx *sqlx.Tx, contributionInfo Contribution) (Contribution, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contribution Contribution + err := executer.GetContext(ctx, &contribution, createContributionQuery, + contributionInfo.UserId, + contributionInfo.RepositoryId, + contributionInfo.ContributionScoreId, + contributionInfo.ContributionType, + contributionInfo.BalanceChange, + contributionInfo.ContributedAt, + contributionInfo.GithubEventId, + ) + if err != nil { + slog.Error("error occured while inserting contributions", "error", err) + return Contribution{}, apperrors.ErrContributionCreationFailed + } + + return contribution, err +} + +func (cr *contributionRepository) GetContributionScoreDetailsByContributionType(ctx context.Context, tx *sqlx.Tx, contributionType string) (ContributionScore, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contributionScoreDetails ContributionScore + err := executer.GetContext(ctx, &contributionScoreDetails, getContributionScoreDetailsByContributionTypeQuery, contributionType) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Warn("no contribution score details found for contribution type", "contributionType", contributionType) + return ContributionScore{}, apperrors.ErrContributionScoreNotFound + } + + slog.Error("error occured while getting contribution score details", "error", err) + return ContributionScore{}, err + } + + return contributionScoreDetails, nil +} + +func (cr *contributionRepository) FetchUserContributions(ctx context.Context, tx *sqlx.Tx, userId int) ([]Contribution, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var userContributions []Contribution + err := executer.SelectContext(ctx, &userContributions, fetchUserContributionsQuery, userId) + if err != nil { + slog.Error("error fetching user contributions", "error", err) + return nil, apperrors.ErrFetchingAllContributions + } + + return userContributions, nil +} + +func (cr *contributionRepository) GetContributionByGithubEventId(ctx context.Context, tx *sqlx.Tx, githubEventId string) (Contribution, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contribution Contribution + err := executer.GetContext(ctx, &contribution, getContributionByGithubEventIdQuery, githubEventId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("contribution not found", "error", err) + return Contribution{}, apperrors.ErrContributionNotFound + } + slog.Error("error fetching contribution by github event id", "error", err) + return Contribution{}, apperrors.ErrFetchingContribution + } + + return contribution, nil + +} + +func (cr *contributionRepository) GetAllContributionTypes(ctx context.Context, tx *sqlx.Tx) ([]ContributionScore, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contributionTypes []ContributionScore + err := executer.SelectContext(ctx, &contributionTypes, getAllContributionTypesQuery) + if err != nil { + slog.Error("error fetching all contribution types", "error", err) + return nil, apperrors.ErrFetchingContributionTypes + } + + return contributionTypes, nil +} + +func (cr *contributionRepository) ListMonthlyContributionSummary(ctx context.Context, tx *sqlx.Tx, year int, month int, userId int) ([]MonthlyContributionSummary, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contributionTypeSummary []MonthlyContributionSummary + err := executer.SelectContext(ctx, &contributionTypeSummary, getMonthlyContributionSummaryQuery, userId, year, month) + if err != nil { + slog.Error("error fetching monthly contribution summary for user", "error", err) + return nil, apperrors.ErrInternalServer + } + + return contributionTypeSummary, nil +} + +func (cr *contributionRepository) GetContributionTypeByContributionScoreId(ctx context.Context, tx *sqlx.Tx, contributionScoreId int) (string, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var contributionType string + err := executer.GetContext(ctx, &contributionType, getContributionTypeByContributionScoreIdQuery, contributionScoreId) + if err != nil { + slog.Error("error occured while getting contribution type by contribution score id", "error", err) + return contributionType, err + } + + return contributionType, nil +} + +func (cr *contributionRepository) UpdateContributionTypeScore(ctx context.Context, tx *sqlx.Tx, configureContributionTypeScore []ConfigureContributionTypeScore) ([]ContributionScore, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + for _, c := range configureContributionTypeScore { + _, err := executer.ExecContext(ctx, updateContributionTypeScoreQuery, c.Score, c.ContributionType) + if err != nil { + slog.Error("failed to update score for contribution type", "error", err) + return nil, apperrors.ErrInternalServer + } + } + + var contributionTypeScores []ContributionScore + err := executer.SelectContext(ctx, &contributionTypeScores, getAllContributionTypesQuery) + if err != nil { + slog.Error("error fetching all contribution type scores", "error", err) + return nil, apperrors.ErrFetchingContributionTypes + } + + return contributionTypeScores, nil +} + +func (cr *contributionRepository) FetchUserContributionsForMonth(ctx context.Context, tx *sqlx.Tx, userId int, monthStartedAt time.Time) ([]Contribution, error) { + executer := cr.BaseRepository.initiateQueryExecuter(tx) + + var userContributionsForMonth []Contribution + err := executer.SelectContext(ctx, &userContributionsForMonth, fetchUserContributionsForMonthQuery, userId, monthStartedAt) + if err != nil { + slog.Error("error fetching user contributions for month", "error", err) + return nil, apperrors.ErrFetchingUserContributionsForMonth + } + + return userContributionsForMonth, nil +} diff --git a/backend/internal/repository/domain.go b/backend/internal/repository/domain.go new file mode 100644 index 00000000..3fbaa00f --- /dev/null +++ b/backend/internal/repository/domain.go @@ -0,0 +1,178 @@ +package repository + +import ( + "database/sql" + "time" +) + +type User struct { + Id int `db:"id"` + GithubId int `db:"github_id"` + GithubUsername string `db:"github_username"` + Email string `db:"email"` + AvatarUrl string `db:"avatar_url"` + CurrentBalance int `db:"current_balance"` + CurrentActiveGoalId sql.NullInt64 `db:"current_active_goal_id"` + IsBlocked bool `db:"is_blocked"` + IsAdmin bool `db:"is_admin"` + Password string `db:"password"` + IsDeleted bool `db:"is_deleted"` + DeletedAt sql.NullTime `db:"deleted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type CreateUserRequestBody struct { + GithubId int `db:"github_id"` + GithubUsername string `db:"github_username"` + AvatarUrl string `db:"avatar_url"` + Email string `db:"email"` + IsAdmin bool `db:"is_admin"` + IsBlocked bool `db:"is_blocked"` +} + +type Contribution struct { + Id int `db:"id"` + UserId int `db:"user_id"` + RepositoryId int `db:"repository_id"` + ContributionScoreId int `db:"contribution_score_id"` + ContributionType string `db:"contribution_type"` + BalanceChange int `db:"balance_change"` + ContributedAt time.Time `db:"contributed_at"` + GithubEventId string `db:"github_event_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Repository struct { + Id int `db:"id"` + GithubRepoId int `db:"github_repo_id"` + RepoName string `db:"repo_name"` + Description string `db:"description"` + LanguagesUrl string `db:"languages_url"` + RepoUrl string `db:"repo_url"` + OwnerName string `db:"owner_name"` + UpdateDate time.Time `db:"update_date"` + ContributorsUrl string `db:"contributors_url"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type ContributionScore struct { + Id int `db:"id"` + AdminId int `db:"admin_id"` + ContributionType string `db:"contribution_type"` + Score int `db:"score"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type Transaction struct { + Id int `db:"id"` + UserId int `db:"user_id"` + ContributionId int `db:"contribution_id"` + IsRedeemed bool `db:"is_redeemed"` + IsGained bool `db:"is_gained"` + TransactedBalance int `db:"transacted_balance"` + TransactedAt time.Time `db:"transacted_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type LeaderboardUser struct { + Id int `db:"id"` + GithubUsername string `db:"github_username"` + AvatarUrl string `db:"avatar_url"` + CurrentBalance int `db:"current_balance"` + Rank int `db:"rank"` +} + +type MonthlyContributionSummary struct { + Type string `db:"contribution_type"` + Count int `db:"contribution_count"` + TotalCoins int `db:"total_coins"` + Month time.Time `db:"month"` +} + +const ( + GoalStatusInProgress = "inProgress" + GoalStatusCompleted = "completed" + GoalStatusIncomplete = "incomplete" +) + +const ( + GoalLevelBeginner = "Beginner" + GoalLevelIntermediate = "Intermediate" + GoalLevelAdvanced = "Advanced" + GoalLevelCustom = "Custom" +) + +type GoalLevel struct { + Id int `db:"id"` + Level string `db:"level"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type UserGoal struct { + Id int `db:"id"` + UserId int `db:"user_id"` + GoalLevelId int `db:"goal_level_id"` + Status string `db:"status"` + MonthStartedAt time.Time `db:"month_started_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type GoalLevelTarget struct { + Id int `db:"id"` + GoalLevelId int `db:"goal_level_id"` + ContributionScoreId int `db:"contribution_score_id"` + Target int `db:"target"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type UserGoalTarget struct { + Id int `db:"id"` + UserGoalId int `db:"user_goal_id"` + ContributionScoreId int `db:"contribution_score_id"` + Target int `db:"target"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type UserGoalProgress struct { + UserGoalTargetId int `db:"user_goal_target_id"` + ContributionId int `db:"contribution_id"` +} + +type Badge struct { + Id int `db:"id"` + UserId int `db:"user_id"` + BadgeType string `db:"badge_type"` + EarnedAt time.Time `db:"earned_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type AdminLoginRequest struct { + Email string `db:"email"` + Password string `db:"password"` +} + +type ConfigureContributionTypeScore struct { + ContributionType string `db:"contribution_type"` + Score int `db:"score"` +} + +type GoalSummary struct { + Id int `db:"id"` + UserId int `db:"user_id"` + SnapshotDate time.Time `db:"snapshot_date"` + IncompleteGoalsCount int `db:"incomplete_goals_count"` + TargetSet int `db:"target_set"` + TargetCompleted int `db:"target_completed"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} diff --git a/backend/internal/repository/goal.go b/backend/internal/repository/goal.go new file mode 100644 index 00000000..00d8cb70 --- /dev/null +++ b/backend/internal/repository/goal.go @@ -0,0 +1,414 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type goalRepository struct { + BaseRepository +} + +type GoalRepository interface { + RepositoryTransaction + ListGoalLevels(ctx context.Context, tx *sqlx.Tx) ([]GoalLevel, error) + GetGoalLevelByLevel(ctx context.Context, tx *sqlx.Tx, level string) (GoalLevel, error) + CreateUserGoalInProgress(ctx context.Context, tx *sqlx.Tx, userGoal UserGoal) (UserGoal, error) + FetchGoalLevelTargetByGoalLevelId(ctx context.Context, tx *sqlx.Tx, goalLevel GoalLevel) ([]GoalLevelTarget, error) + FetchGoalLevelTargetByGoalLevel(ctx context.Context, tx *sqlx.Tx, goalLevel GoalLevel) ([]GoalLevelTarget, error) + CreateUserGoalTarget(ctx context.Context, tx *sqlx.Tx, userGoalTarget UserGoalTarget) (UserGoalTarget, error) + CreateUserGoalProgress(ctx context.Context, tx *sqlx.Tx, userGoalProgress UserGoalProgress) (UserGoalProgress, error) + GetUserCurrentGoal(ctx context.Context, tx *sqlx.Tx, userId int) (UserGoal, error) + UpdateUserGoalStatus(ctx context.Context, tx *sqlx.Tx, userGoal UserGoal) (UserGoal, error) + ListUserGoalTargetsByUserGoalId(ctx context.Context, tx *sqlx.Tx, userGoalId int) ([]UserGoalTarget, error) + GetContributionProgressCount(ctx context.Context, tx *sqlx.Tx, userGoalTargetId int) (int, error) + GetGoalLevelById(ctx context.Context, tx *sqlx.Tx, goalLevelId int) (GoalLevel, error) + FetchInProgressUserGoalsOfPreviousMonth(ctx context.Context, tx *sqlx.Tx) ([]UserGoal, error) + CalculateUserIncompleteGoalsUntilDay(ctx context.Context, tx *sqlx.Tx, userID int) (int, error) + CreateUserGoalSummary(ctx context.Context, tx *sqlx.Tx, userGoalSummary GoalSummary) (GoalSummary, error) + FetchUserGoalSummary(ctx context.Context, tx *sqlx.Tx, userId int) ([]GoalSummary, error) + GetUserGoalSummaryBySnapshotDate(ctx context.Context, tx *sqlx.Tx, snapshotDate time.Time, userId int) (*GoalSummary, error) + UpdateUserGoalSummary(ctx context.Context, tx *sqlx.Tx, goalSummaryId int, userGoalSummary GoalSummary) (GoalSummary, error) +} + +func NewGoalRepository(db *sqlx.DB) GoalRepository { + return &goalRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + listGoalLevelQuery = "SELECT * from goal_level;" + + getGoalLevelByLevelQuery = "SELECT * from goal_level where level=$1" + + createUserGoalInProgressQuery = ` + INSERT INTO user_goal( + user_id, + goal_level_id, + status, + month_started_at + ) + VALUES + ($1, $2, $3, $4) + RETURNING *` + + fetchGoalLevelTargetByGoalLevelIdQuery = "SELECT * FROM goal_level_target WHERE goal_level_id=$1" + + fetchGoalLevelTargetByGoalLevelQuery = "SELECT * FROM goal_level_target WHERE goal_level_id=(SELECT id FROM goal_level WHERE level=$1)" + + createUserGoalTargetQuery = ` + INSERT INTO user_goal_target( + user_goal_id, + contribution_score_id, + target + ) + VALUES + ($1, $2, $3) + RETURNING *` + + createUserGoalProgressQuery = ` + INSERT INTO user_goal_progress( + user_goal_target_id, + contribution_id + ) + VALUES + ($1, $2) + RETURNING *` + + getUserCurrentGoalQuery = ` + SELECT * FROM user_goal + WHERE date_trunc('month', month_started_at) = date_trunc('month', NOW()) + AND status != 'incomplete' + AND user_id = $1 + ORDER BY created_at DESC + LIMIT 1` + + updateUserGoalStatusQuery = "UPDATE user_goal SET status=$1, updated_at=$2 WHERE id=$3 RETURNING *" + + listUserGoalTargetsByUserGoalIdQuery = "SELECT * from user_goal_target WHERE user_goal_id=$1" + + getContributionProgressCountQuery = "SELECT COUNT(*) FROM user_goal_progress WHERE user_goal_target_id=$1" + + getGoalLevelByIdQuery = "SELECT * FROM goal_level where id=$1" + + fetchInProgressUserGoalsOfPreviousMonthQuery = ` + SELECT * FROM user_goal + WHERE date_trunc('month', month_started_at) = date_trunc('month', NOW() - interval '1 month') + WHERE status='inProgress'` + + calculateUserIncompleteGoalsUntilDayQuery = ` + SELECT COUNT(*) FROM user_goal + WHERE date_trunc('month', month_started_at) = date_trunc('month', NOW()) + AND status = 'incomplete' + AND user_id = $1` + + createUserGoalSummaryQuery = ` + INSERT INTO goal_summary( + user_id, + snapshot_date, + incomplete_goals_count, + target_set, + target_completed + ) + VALUES + ($1, $2, $3, $4, $5) + RETURNING *` + + fetchUserGoalSummaryQuery = "SELECT * FROM goal_summary WHERE user_id=$1" + + getUserGoalSummaryBySnapshotDateQuery = ` + SELECT * FROM goal_summary + WHERE user_id = $2 + AND snapshot_date::date = $1::date + LIMIT 1` + + updateUserGoalSummaryQuery = "UPDATE goal_summary SET snapshot_date=$2, incomplete_goals_count=$3, target_set=$4, target_completed=$5 where id=$1 " + + fetchUsersWithActiveGoalsForCurrentMonthQuery = ` + SELECT DISTINCT(user_id) FROM user_goal + WHERE date_trunc('month', month_started_at) = date_trunc('month', NOW())` +) + +func (gr *goalRepository) ListGoalLevels(ctx context.Context, tx *sqlx.Tx) ([]GoalLevel, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var goalLevels []GoalLevel + err := executer.SelectContext(ctx, &goalLevels, listGoalLevelQuery) + if err != nil { + slog.Error("error fetching goal levels", "error", err) + return nil, apperrors.ErrFetchingGoals + } + + return goalLevels, nil +} + +func (gr *goalRepository) GetGoalLevelByLevel(ctx context.Context, tx *sqlx.Tx, level string) (GoalLevel, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var goalLevel GoalLevel + err := executer.GetContext(ctx, &goalLevel, getGoalLevelByLevelQuery, level) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("error goal level not found", "error", err) + return GoalLevel{}, apperrors.ErrGoalLevelNotFound + } + + slog.Error("error occured while getting goal level by level", "error", err) + return GoalLevel{}, apperrors.ErrFailedToGetGoalLevel + } + + return goalLevel, nil +} + +func (gr *goalRepository) CreateUserGoalInProgress(ctx context.Context, tx *sqlx.Tx, userGoal UserGoal) (UserGoal, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var createdUserGoal UserGoal + err := executer.GetContext(ctx, &createdUserGoal, createUserGoalInProgressQuery, userGoal.UserId, userGoal.GoalLevelId, userGoal.Status, userGoal.MonthStartedAt) + if err != nil { + slog.Error("failed to create user goal in progress", "error", err) + return UserGoal{}, apperrors.ErrUserGoalCreationFailed + } + + return createdUserGoal, nil +} + +func (gr *goalRepository) FetchGoalLevelTargetByGoalLevelId(ctx context.Context, tx *sqlx.Tx, goalLevel GoalLevel) ([]GoalLevelTarget, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var goalLevelTargets []GoalLevelTarget + err := executer.SelectContext(ctx, &goalLevelTargets, fetchGoalLevelTargetByGoalLevelIdQuery, goalLevel.Id) + if err != nil { + slog.Error("error fetching goal level target by goal level", "error", err) + return nil, apperrors.ErrFetchingGoalLevelTargets + } + + return goalLevelTargets, nil +} + +func (gr *goalRepository) FetchGoalLevelTargetByGoalLevel(ctx context.Context, tx *sqlx.Tx, goalLevel GoalLevel) ([]GoalLevelTarget, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var goalLevelTargets []GoalLevelTarget + err := executer.SelectContext(ctx, &goalLevelTargets, fetchGoalLevelTargetByGoalLevelQuery, goalLevel.Level) + if err != nil { + slog.Error("error fetching goal level target by goal level", "error", err) + return nil, apperrors.ErrFetchingGoalLevelTargets + } + + return goalLevelTargets, nil +} + +func (gr *goalRepository) CreateUserGoalTarget(ctx context.Context, tx *sqlx.Tx, userGoalTarget UserGoalTarget) (UserGoalTarget, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var createdUserGoalTarget UserGoalTarget + err := executer.GetContext(ctx, &createdUserGoalTarget, createUserGoalTargetQuery, userGoalTarget.UserGoalId, userGoalTarget.ContributionScoreId, userGoalTarget.Target) + if err != nil { + slog.Error("error creating user goal target", "error", err) + return UserGoalTarget{}, apperrors.ErrUserGoalTargetCreationFailed + } + + return createdUserGoalTarget, nil +} + +func (gr *goalRepository) CreateUserGoalProgress(ctx context.Context, tx *sqlx.Tx, userGoalProgress UserGoalProgress) (UserGoalProgress, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var createdUserGoalProgress UserGoalProgress + err := executer.GetContext(ctx, &createdUserGoalProgress, createUserGoalProgressQuery, userGoalProgress.UserGoalTargetId, userGoalProgress.ContributionId) + if err != nil { + slog.Error("error creating user goal target", "error", err) + return UserGoalProgress{}, apperrors.ErrUserGoalProgressCreationFailed + } + + return createdUserGoalProgress, nil +} + +func (gr *goalRepository) GetUserCurrentGoal(ctx context.Context, tx *sqlx.Tx, userId int) (UserGoal, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var userGoal UserGoal + err := executer.GetContext(ctx, &userGoal, getUserCurrentGoalQuery, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Warn("no user goal found for current month", "userId", userId) + return UserGoal{}, apperrors.ErrUserGoalNotFound + } + + slog.Error("error occurred while getting latest user goal for current month", "error", err) + return UserGoal{}, apperrors.ErrFailedToGetUserGoal + } + + return userGoal, nil +} + +func (gr *goalRepository) UpdateUserGoalStatus(ctx context.Context, tx *sqlx.Tx, userGoal UserGoal) (UserGoal, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var updatedGoal UserGoal + err := executer.GetContext(ctx, &updatedGoal, updateUserGoalStatusQuery, + userGoal.Status, + time.Now().UTC(), + userGoal.Id, + ) + if err != nil { + slog.Error("failed to update user goal id", "error", err) + return UserGoal{}, apperrors.ErrInternalServer + } + + return updatedGoal, nil +} + +func (gr *goalRepository) ListUserGoalTargetsByUserGoalId(ctx context.Context, tx *sqlx.Tx, userGoalId int) ([]UserGoalTarget, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var userGoalTargets []UserGoalTarget + err := executer.SelectContext(ctx, &userGoalTargets, listUserGoalTargetsByUserGoalIdQuery, userGoalId) + if err != nil { + slog.Error("error fetching user goal targets by user goal id", "error", err) + return nil, apperrors.ErrFetchingGoalLevelTargets + } + + return userGoalTargets, nil +} + +func (gr *goalRepository) GetContributionProgressCount(ctx context.Context, tx *sqlx.Tx, userGoalTargetId int) (int, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var contributionProgressCount int + err := executer.GetContext(ctx, &contributionProgressCount, getContributionProgressCountQuery, userGoalTargetId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("no contribution for the target") + return 0, nil + } + + slog.Error("error counting progress of given contribution target", "error", err) + return 0, apperrors.ErrInternalServer + } + + return contributionProgressCount, nil +} + +func (gr *goalRepository) GetGoalLevelById(ctx context.Context, tx *sqlx.Tx, goalLevelId int) (GoalLevel, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var goalLevel GoalLevel + err := executer.GetContext(ctx, &goalLevel, getGoalLevelByIdQuery, goalLevelId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("error goal level not found", "error", err) + return GoalLevel{}, apperrors.ErrGoalLevelNotFound + } + + slog.Error("error occured while getting goal level by id", "error", err) + return GoalLevel{}, apperrors.ErrFailedToGetGoalLevel + } + + return goalLevel, nil +} + +func (gr *goalRepository) FetchInProgressUserGoalsOfPreviousMonth(ctx context.Context, tx *sqlx.Tx) ([]UserGoal, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var userGoals []UserGoal + err := executer.SelectContext(ctx, &userGoals, fetchInProgressUserGoalsOfPreviousMonthQuery) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Info("no in progress goals") + return nil, nil + } + slog.Error("error occurred while fetching in progress user goals for previous month", "error", err) + return nil, apperrors.ErrFailedToGetUserGoal + } + + return userGoals, nil +} + +func (gr *goalRepository) CalculateUserIncompleteGoalsUntilDay(ctx context.Context, tx *sqlx.Tx, userID int) (int, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var userIncompleteGoalCount int + err := executer.GetContext(ctx, &userIncompleteGoalCount, calculateUserIncompleteGoalsUntilDayQuery, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Info("no incomplete goals for month") + return 0, nil + } + slog.Error("error getting incomplete goals until day", "error", err) + return 0, err + } + + return userIncompleteGoalCount, nil +} + +func (gr *goalRepository) CreateUserGoalSummary(ctx context.Context, tx *sqlx.Tx, userGoalSummary GoalSummary) (GoalSummary, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var createdUserGoalSummary GoalSummary + err := executer.GetContext(ctx, &createdUserGoalSummary, createUserGoalSummaryQuery, userGoalSummary.UserId, userGoalSummary.SnapshotDate, userGoalSummary.IncompleteGoalsCount, userGoalSummary.TargetSet, userGoalSummary.TargetCompleted) + if err != nil { + slog.Error("error creating user goal summary", "error", err) + return GoalSummary{}, err + } + + return createdUserGoalSummary, nil +} + +func (gr *goalRepository) FetchUserGoalSummary(ctx context.Context, tx *sqlx.Tx, userId int) ([]GoalSummary, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var usersGoalSummary []GoalSummary + err := executer.SelectContext(ctx, &usersGoalSummary, fetchUserGoalSummaryQuery, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + slog.Error("error fetching users goal summary", "error", err) + return nil, err + } + + return usersGoalSummary, nil +} + +func (gr *goalRepository) GetUserGoalSummaryBySnapshotDate(ctx context.Context, tx *sqlx.Tx, snapshotDate time.Time, userId int) (*GoalSummary, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + var userGoalSummary GoalSummary + err := executer.GetContext(ctx, &userGoalSummary, getUserGoalSummaryBySnapshotDateQuery, snapshotDate, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + slog.Error("error getting user goal summary", "error", err) + return nil, err + } + + return &userGoalSummary, nil +} + +func (gr *goalRepository) UpdateUserGoalSummary(ctx context.Context, tx *sqlx.Tx, goalSummaryId int, userGoalSummary GoalSummary) (GoalSummary, error) { + executer := gr.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, updateUserGoalSummaryQuery, + goalSummaryId, + userGoalSummary.SnapshotDate, + userGoalSummary.IncompleteGoalsCount, + userGoalSummary.TargetSet, + userGoalSummary.TargetCompleted, + ) + if err != nil { + slog.Error("failed to update user goal summary", "error", err) + return GoalSummary{}, apperrors.ErrInternalServer + } + + return GoalSummary{}, nil +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go new file mode 100644 index 00000000..4b2823c9 --- /dev/null +++ b/backend/internal/repository/repository.go @@ -0,0 +1,170 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type repositoryRepository struct { + BaseRepository +} + +type RepositoryRepository interface { + RepositoryTransaction + GetRepoByGithubId(ctx context.Context, tx *sqlx.Tx, repoGithubId int) (Repository, error) + GetRepoByRepoId(ctx context.Context, tx *sqlx.Tx, repoId int) (Repository, error) + CreateRepository(ctx context.Context, tx *sqlx.Tx, repository Repository) (Repository, error) + GetUserRepoTotalCoins(ctx context.Context, tx *sqlx.Tx, userId int, repoId int) (int, error) + FetchUsersContributedRepos(ctx context.Context, tx *sqlx.Tx, userId int) ([]Repository, error) + FetchUserContributionsInRepo(ctx context.Context, tx *sqlx.Tx, userId int, repoGithubId int) ([]Contribution, error) + FetchUserContributedReposCount(ctx context.Context, tx *sqlx.Tx, userId int) (int, error) +} + +func NewRepositoryRepository(db *sqlx.DB) RepositoryRepository { + return &repositoryRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + getRepoByGithubIdQuery = `SELECT * from repositories where github_repo_id=$1` + + getrepoByRepoIdQuery = `SELECT * from repositories where id=$1` + + createRepositoryQuery = ` + INSERT INTO repositories ( + github_repo_id, + repo_name, + description, + languages_url, + repo_url, + owner_name, + update_date, + contributors_url + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *` + + getUserRepoTotalCoinsQuery = `SELECT sum(balance_change) from contributions where user_id = $1 and repository_id = $2;` + + fetchUsersContributedReposQuery = `SELECT * from repositories where id in (SELECT repository_id from contributions where user_id=$1);` + + fetchUserContributionsInRepoQuery = `SELECT * from contributions where repository_id=$1 and user_id=$2;` + + fetchUserContributedReposCountQuery = `SELECT COUNT(DISTINCT repository_id) AS unique_repo_count FROM contributions WHERE user_id = $1;` +) + +func (rr *repositoryRepository) GetRepoByGithubId(ctx context.Context, tx *sqlx.Tx, repoGithubId int) (Repository, error) { + executer := rr.BaseRepository.initiateQueryExecuter(tx) + + var repository Repository + err := executer.GetContext(ctx, &repository, getRepoByGithubIdQuery, repoGithubId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("repository not found", "error", err) + return Repository{}, apperrors.ErrRepoNotFound + } + slog.Error("error occurred while getting repository by repo github id", "error", err) + return Repository{}, apperrors.ErrInternalServer + } + + return repository, nil + +} + +func (rr *repositoryRepository) GetRepoByRepoId(ctx context.Context, tx *sqlx.Tx, repoId int) (Repository, error) { + executer := rr.BaseRepository.initiateQueryExecuter(tx) + + var repository Repository + err := executer.GetContext(ctx, &repository, getrepoByRepoIdQuery, repoId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("repository not found", "error", err) + return Repository{}, apperrors.ErrRepoNotFound + } + slog.Error("error occurred while getting repository by id", "error", err) + return Repository{}, apperrors.ErrInternalServer + } + + return repository, nil +} + +func (rr *repositoryRepository) CreateRepository(ctx context.Context, tx *sqlx.Tx, repositoryInfo Repository) (Repository, error) { + executer := rr.BaseRepository.initiateQueryExecuter(tx) + + var repository Repository + err := executer.GetContext(ctx, &repository, createRepositoryQuery, + repositoryInfo.GithubRepoId, + repositoryInfo.RepoName, + repositoryInfo.Description, + repositoryInfo.LanguagesUrl, + repositoryInfo.RepoUrl, + repositoryInfo.OwnerName, + repositoryInfo.UpdateDate, + repositoryInfo.ContributorsUrl, + ) + if err != nil { + slog.Error("error occured while creating repository", "error", err) + return Repository{}, apperrors.ErrInternalServer + } + + return repository, nil + +} + +func (r *repositoryRepository) GetUserRepoTotalCoins(ctx context.Context, tx *sqlx.Tx, userId int, repoId int) (int, error) { + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var totalCoins int + err := executer.GetContext(ctx, &totalCoins, getUserRepoTotalCoinsQuery, userId, repoId) + if err != nil { + slog.Error("error calculating total coins earned by user for the repository", "error", err) + return 0, apperrors.ErrCalculatingUserRepoTotalCoins + } + + return totalCoins, nil +} + +func (r *repositoryRepository) FetchUsersContributedRepos(ctx context.Context, tx *sqlx.Tx, userId int) ([]Repository, error) { + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var usersContributedRepos []Repository + err := executer.SelectContext(ctx, &usersContributedRepos, fetchUsersContributedReposQuery, userId) + if err != nil { + slog.Error("error fetching users contributed repositories", "error", err) + return nil, apperrors.ErrFetchingUsersContributedRepos + } + + return usersContributedRepos, nil +} + +func (r *repositoryRepository) FetchUserContributionsInRepo(ctx context.Context, tx *sqlx.Tx, userId int, repoGithubId int) ([]Contribution, error) { + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var userContributionsInRepo []Contribution + err := executer.SelectContext(ctx, &userContributionsInRepo, fetchUserContributionsInRepoQuery, repoGithubId, userId) + if err != nil { + slog.Error("error fetching users contribution in repository", "error", err) + return nil, apperrors.ErrFetchingUserContributionsInRepo + } + + return userContributionsInRepo, nil +} + +func (r *repositoryRepository) FetchUserContributedReposCount(ctx context.Context, tx *sqlx.Tx, userId int) (int, error) { + executer := r.BaseRepository.initiateQueryExecuter(tx) + + var usersContributedReposCount int + err := executer.GetContext(ctx, &usersContributedReposCount, fetchUserContributedReposCountQuery, userId) + if err != nil { + slog.Error("error fetching user contributed repos count", "error", err) + return 0, apperrors.ErrFetchingUsersContributedReposCount + } + + return usersContributedReposCount, nil +} diff --git a/backend/internal/repository/seed.go b/backend/internal/repository/seed.go new file mode 100644 index 00000000..81cd7994 --- /dev/null +++ b/backend/internal/repository/seed.go @@ -0,0 +1,105 @@ +package repository + +import ( + "fmt" + "log/slog" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/config" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +func SeedData(appCfg config.AppConfig) error { + dbInfo := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + appCfg.Database.Host, + appCfg.Database.Port, + appCfg.Database.User, + appCfg.Database.Password, + appCfg.Database.Name, + ) + + db, err := sqlx.Connect("postgres", dbInfo) + if err != nil { + return err + } + + seedQueries := []string{ + // Insert default admin user + `INSERT INTO users VALUES ( + DEFAULT, + 0, + 'admin', + '', + '', + 0, + false, + true, + '$2a$14$gWxgkAc0uPTxkSBlMTudZusI/4QmQQssMXW8NjjZTJqsDx7PKdBvG', + false, + DEFAULT, + DEFAULT, + DEFAULT + )`, + + // Contribution scores + `INSERT INTO contribution_score (admin_id, contribution_type, score) + VALUES + (1, 'CommitAdded', 10), + (1, 'IssueOpened', 10), + (1, 'IssueClosed', 20), + (1, 'IssueCompleted', 30), + (1, 'PullRequestOpened', 40), + (1, 'PullRequestMerged', 50), + (1, 'PullRequestUpdated', 60), + (1, 'IssueComment', 10), + (1, 'PullRequestComment', 10)`, + + // Goal levels + `INSERT INTO goal_level (level) + VALUES + ('Custom'), + ('Beginner'), + ('Intermediate'), + ('Advanced')`, + + // Goal level targets + `INSERT INTO goal_level_target (goal_level_id, contribution_score_id, target) + VALUES + (2, 5, 1), + + (3, 1, 2), + (3, 2, 3), + (3, 3, 4), + (3, 4, 1), + (3, 5, 2), + (3, 6, 3), + (3, 7, 4), + (3, 8, 1), + (3, 9, 2), + + (4, 1, 3), + (4, 2, 4), + (4, 3, 1), + (4, 4, 2), + (4, 5, 3), + (4, 6, 4), + (4, 7, 1), + (4, 8, 2), + (4, 9, 3)`, + } + + for _, query := range seedQueries { + _, err := db.Exec(query) + if err != nil { + slog.Error("Failed to execute seed query", + "query", query, + "error", err, + ) + return apperrors.ErrInternalServer + } + } + + slog.Info("Seed data loaded successfully.") + return nil +} diff --git a/backend/internal/repository/transaction.go b/backend/internal/repository/transaction.go new file mode 100644 index 00000000..b1545654 --- /dev/null +++ b/backend/internal/repository/transaction.go @@ -0,0 +1,79 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type transactionRepository struct { + BaseRepository +} + +type TransactionRepository interface { + RepositoryTransaction + CreateTransaction(ctx context.Context, tx *sqlx.Tx, transactionInfo Transaction) (Transaction, error) + GetTransactionByContributionId(ctx context.Context, tx *sqlx.Tx, contributionId int) (Transaction, error) +} + +func NewTransactionRepository(db *sqlx.DB) TransactionRepository { + return &transactionRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + createTransactionQuery = `INSERT INTO transactions ( + user_id, + contribution_id, + is_redeemed, + is_gained, + transacted_balance, + transacted_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *` + + getTransactionByContributionIdQuery = `SELECT * from transactions where contribution_id=$1` +) + +func (tr *transactionRepository) CreateTransaction(ctx context.Context, tx *sqlx.Tx, transactionInfo Transaction) (Transaction, error) { + executer := tr.BaseRepository.initiateQueryExecuter(tx) + + var transaction Transaction + err := executer.GetContext(ctx, &transaction, createTransactionQuery, + transactionInfo.UserId, + transactionInfo.ContributionId, + transactionInfo.IsRedeemed, + transactionInfo.IsGained, + transactionInfo.TransactedBalance, + transactionInfo.TransactedAt, + ) + if err != nil { + slog.Error("error occured while creating transaction", "error", err) + return Transaction{}, apperrors.ErrTransactionCreationFailed + } + + return transaction, nil +} + +func (tr *transactionRepository) GetTransactionByContributionId(ctx context.Context, tx *sqlx.Tx, contributionId int) (Transaction, error) { + executer := tr.BaseRepository.initiateQueryExecuter(tx) + + var transaction Transaction + err := executer.GetContext(ctx, &transaction, getTransactionByContributionIdQuery, contributionId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("transaction for the contribution id does not exist", "error", err) + return Transaction{}, apperrors.ErrTransactionNotFound + } + slog.Error("error fetching transaction using contributionid", "error", err) + return Transaction{}, err + } + + return transaction, nil +} diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go new file mode 100644 index 00000000..6a1c4052 --- /dev/null +++ b/backend/internal/repository/user.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "log/slog" + "time" + + "github.com/jmoiron/sqlx" + "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" +) + +type userRepository struct { + BaseRepository +} + +type UserRepository interface { + RepositoryTransaction + GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) + GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error) + CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error) + UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error + MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userId int, deletedAt time.Time) error + RecoverAccountInGracePeriod(ctx context.Context, tx *sqlx.Tx, userId int) error + HardDeleteUsers(ctx context.Context, tx *sqlx.Tx) error + GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) + UpdateUserCurrentBalance(ctx context.Context, tx *sqlx.Tx, user User) error + GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]LeaderboardUser, error) + GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) + GetAdminByCredentials(ctx context.Context, tx *sqlx.Tx, adminInfo AdminLoginRequest) (User, error) + GetAllUsers(ctx context.Context, tx *sqlx.Tx) ([]User, error) + UpdateUserBlockStatus(ctx context.Context, tx *sqlx.Tx, userID int, block bool) error +} + +func NewUserRepository(db *sqlx.DB) UserRepository { + return &userRepository{ + BaseRepository: BaseRepository{db}, + } +} + +const ( + getUserByIdQuery = "SELECT * from users where id=$1" + + getUserByGithubIdQuery = "SELECT * from users where github_id=$1" + + createUserQuery = ` + INSERT INTO users ( + github_id, + github_username, + email, + avatar_url + ) + VALUES ($1, $2, $3, $4) + RETURNING *` + + updateEmailQuery = "UPDATE users SET email=$1, updated_at=$2 where id=$3" + + markUserAsDeletedQuery = "UPDATE users SET is_deleted = TRUE, deleted_at=$1, updated_at=$2 where id = $3" + + recoverAccountInGracePeriodQuery = "UPDATE users SET is_deleted = false, deleted_at = NULL, updated_at=$1 where id = $2" + + hardDeleteUsersQuery = "DELETE FROM users WHERE is_deleted = TRUE AND deleted_at <= $1" + + getAllUsersGithubIdQuery = "SELECT github_id from users where is_admin=false" + + updateUserCurrentBalanceQuery = "UPDATE users SET current_balance=$1, updated_at=$2 where id=$3" + + getAllUsersRankQuery = ` + SELECT + id, + github_username, + avatar_url, + current_balance, + RANK() over (ORDER BY current_balance DESC) AS rank + FROM users + WHERE is_admin=false + AND is_deleted=false + ORDER BY current_balance DESC` + + getCurrentUserRankQuery = ` + SELECT * + FROM + ( + SELECT + id, + github_username, + avatar_url, + current_balance, + RANK() OVER (ORDER BY current_balance DESC) AS rank + FROM users + WHERE is_admin=false + AND is_deleted=false + ) + ranked_users + WHERE id = $1;` + + verifyAdminCredentialsQuery = "SELECT * FROM users where email = $1 and is_admin=true" + + getAllUsersQuery = "SELECT * FROM users where is_admin=false" + + updateUserBlockStatusQuery = "UPDATE users SET is_blocked=$1, updated_at=$2 where id=$3" +) + +func (ur *userRepository) GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var user User + err := executer.GetContext(ctx, &user, getUserByIdQuery, userId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("user not found", "error", err) + return User{}, apperrors.ErrUserNotFound + } + slog.Error("error occurred while getting user by id", "error", err) + return User{}, apperrors.ErrInternalServer + } + + return user, nil +} + +func (ur *userRepository) GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var user User + err := executer.GetContext(ctx, &user, getUserByGithubIdQuery, githubId) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("user not found", "error", err) + return User{}, apperrors.ErrUserNotFound + } + slog.Error("error occurred while getting user by github id", "error", err) + return User{}, apperrors.ErrInternalServer + } + + return user, nil +} + +func (ur *userRepository) CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var user User + err := executer.GetContext(ctx, &user, createUserQuery, + userInfo.GithubId, + userInfo.GithubUsername, + userInfo.Email, + userInfo.AvatarUrl) + + if err != nil { + slog.Error("error occurred while creating user", "error", err) + return User{}, apperrors.ErrUserCreationFailed + } + + return user, nil + +} + +func (ur *userRepository) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, updateEmailQuery, email, time.Now(), userId) + if err != nil { + slog.Error("failed to update user email", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) MarkUserAsDeleted(ctx context.Context, tx *sqlx.Tx, userId int, deletedAt time.Time) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, markUserAsDeletedQuery, deletedAt, time.Now(), userId) + if err != nil { + slog.Error("unable to mark user as deleted", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) RecoverAccountInGracePeriod(ctx context.Context, tx *sqlx.Tx, userId int) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, recoverAccountInGracePeriodQuery, time.Now(), userId) + if err != nil { + slog.Error("unable to reverse the soft delete ", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) HardDeleteUsers(ctx context.Context, tx *sqlx.Tx) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + threshold := time.Now().Add(-90 * 1 * time.Second) + + _, err := executer.ExecContext(ctx, hardDeleteUsersQuery, threshold) + if err != nil { + slog.Error("error deleting users that are soft deleted for more than three months", "error", err) + return apperrors.ErrInternalServer + } + + return err +} + +func (ur *userRepository) GetAllUsersGithubId(ctx context.Context, tx *sqlx.Tx) ([]int, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var githubIds []int + err := executer.SelectContext(ctx, &githubIds, getAllUsersGithubIdQuery) + if err != nil { + slog.Error("failed to get github usernames", "error", err) + return nil, apperrors.ErrInternalServer + } + + return githubIds, nil +} + +func (ur *userRepository) UpdateUserCurrentBalance(ctx context.Context, tx *sqlx.Tx, user User) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, updateUserCurrentBalanceQuery, user.CurrentBalance, time.Now(), user.Id) + if err != nil { + slog.Error("failed to update user balance change", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} + +func (ur *userRepository) GetAllUsersRank(ctx context.Context, tx *sqlx.Tx) ([]LeaderboardUser, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var leaderboard []LeaderboardUser + err := executer.SelectContext(ctx, &leaderboard, getAllUsersRankQuery) + if err != nil { + slog.Error("failed to get users rank", "error", err) + return nil, apperrors.ErrInternalServer + } + + return leaderboard, nil +} + +func (ur *userRepository) GetCurrentUserRank(ctx context.Context, tx *sqlx.Tx, userId int) (LeaderboardUser, error) { + + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var currentUserRank LeaderboardUser + err := executer.GetContext(ctx, ¤tUserRank, getCurrentUserRankQuery, userId) + if err != nil { + slog.Error("failed to get user rank", "error", err) + return LeaderboardUser{}, apperrors.ErrInternalServer + } + + return currentUserRank, nil +} + +func (ur *userRepository) GetAdminByCredentials(ctx context.Context, tx *sqlx.Tx, adminInfo AdminLoginRequest) (User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var admin User + err := executer.GetContext(ctx, &admin, verifyAdminCredentialsQuery, adminInfo.Email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Error("invalid admin credentials", "error", err) + return User{}, apperrors.ErrInvalidCredentials + } + slog.Error("failed to verify admin credentials", "error", err) + return User{}, apperrors.ErrInternalServer + } + + return admin, nil +} + +func (ur *userRepository) GetAllUsers(ctx context.Context, tx *sqlx.Tx) ([]User, error) { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + var users []User + err := executer.SelectContext(ctx, &users, getAllUsersQuery) + if err != nil { + slog.Error("error occurred while getting all users", "error", err) + return nil, apperrors.ErrInternalServer + } + + return users, nil +} + +func (ur *userRepository) UpdateUserBlockStatus(ctx context.Context, tx *sqlx.Tx, userID int, block bool) error { + executer := ur.BaseRepository.initiateQueryExecuter(tx) + + _, err := executer.ExecContext(ctx, updateUserBlockStatusQuery, block, time.Now(), userID) + if err != nil { + slog.Error("failed to update user block status", "error", err) + return apperrors.ErrInternalServer + } + + return nil +} diff --git a/backend/run-backend.sh b/backend/run-backend.sh new file mode 100755 index 00000000..b5175fd3 --- /dev/null +++ b/backend/run-backend.sh @@ -0,0 +1,4 @@ +export CONFIG_PATH=local.yaml + +export GOOGLE_APPLICATION_CREDENTIALS=/home/josh/Documents/cobalt-alliance-459708-h5-3e1df629b2ff.json +go run ./cmd/main.go \ No newline at end of file diff --git a/backend/run-migrations-up.sh b/backend/run-migrations-up.sh new file mode 100755 index 00000000..21f004af --- /dev/null +++ b/backend/run-migrations-up.sh @@ -0,0 +1,3 @@ +export CONFIG_PATH=local.yaml + +go run ./internal/db/migrate.go up \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go deleted file mode 100644 index 35ae97a7..00000000 --- a/cmd/main.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - - "os/signal" - "syscall" - "time" - - "github.com/joshsoftware/code-curiosity-2025/internal/app" - "github.com/joshsoftware/code-curiosity-2025/internal/config" -) - -func main() { - ctx := context.Background() - - cfg,err := config.LoadAppConfig() - if err != nil { - slog.Error("error loading app config", "error", err) - return - } - - - db, err := config.InitDataStore(cfg) - if err != nil { - slog.Error("error initializing database", "error", err) - return - } - defer db.Close() - - dependencies := app.InitDependencies(db,cfg) - - router := app.NewRouter(dependencies) - - server := http.Server{ - Addr: fmt.Sprintf(":%s", cfg.HTTPServer.Port), - Handler: router, - } - - serverRunning := make(chan os.Signal, 1) - - signal.Notify( - serverRunning, - syscall.SIGABRT, - syscall.SIGALRM, - syscall.SIGBUS, - syscall.SIGINT, - syscall.SIGTERM, - ) - - go func() { - slog.Info("server listening at", "port", cfg.HTTPServer.Port) - - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("server error", "error", err) - serverRunning <- syscall.SIGINT - } - }() - - <-serverRunning - - slog.Info("shutting down the server") - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - if err := server.Shutdown(ctx); err != nil { - slog.Error("cannot shut HTTP server down gracefully", "error", err) - } - - slog.Info("server shutdown successfully") -} diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 00000000..01301365 --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,56 @@ +import pluginJs from "@eslint/js"; +import pluginImport from "eslint-plugin-import"; +import pluginA11y from "eslint-plugin-jsx-a11y"; +import pluginPrettier from "eslint-plugin-prettier"; +import pluginReact from "eslint-plugin-react"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +/** @type {import('eslint').Linter.FlatConfig[]} */ +export default [ + { + files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], + languageOptions: { + globals: globals.browser, + ecmaVersion: "latest", + sourceType: "module", + parser: tseslint.parser + }, + plugins: { + react: pluginReact, + "@typescript-eslint": tseslint.plugin, + prettier: pluginPrettier, + "jsx-a11y": pluginA11y, + import: pluginImport + }, + rules: { + ...pluginJs.configs.recommended.rules, + ...tseslint.configs.recommended.rules, + ...pluginReact.configs.recommended.rules, + "prettier/prettier": "error", + "react/display-name": "off", + "jsx-a11y/anchor-is-valid": "off", + "jsx-a11y/label-has-for": "off", + camelcase: "off", + "func-names": ["error", "never"], + "import/prefer-default-export": "off", + "import/no-anonymous-default-export": "off", + "import/no-extraneous-dependencies": "off", + "no-multi-spaces": "off", + "class-methods-use-this": "off", + "no-class-assign": "off", + "key-spacing": "off", + "lines-between-class-members": "off", + "no-param-reassign": "off", + "consistent-return": "off", + "jsx-a11y/href-no-hash": "off", + "import/no-unresolved": "off", + "no-tabs": "off", + "react/react-in-jsx-scope": "off", + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": "error", + "react/jsx-filename-extension": ["error", { extensions: [".tsx"] }], + "react/prop-types": "off" + } + } +]; diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 00000000..3c92e9e0 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,17 @@ +{ + "semi": true, + "tabWidth": 2, + "printWidth": 80, + "singleQuote": false, + "trailingComma": "none", + "arrowParens": "avoid", + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxSingleQuote": false, + "proseWrap": "always", + "quoteProps": "as-needed", + "requirePragma": false, + "useTabs": false, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 00000000..c2eba99e --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/shared/components", + "utils": "@/shared/utils/tailwindcss", + "ui": "@/shared/components/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 00000000..c1b016ba --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +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 00000000..d9c2ce70 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Code Curiosity + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..ca01eccb --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6159 @@ +{ + "name": "code-curiosity-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "code-curiosity-frontend", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", + "axios": "^1.10.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^17.2.0", + "lucide-react": "^0.525.0", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-hook-form": "^7.62.0", + "react-router-dom": "^7.7.0", + "sonner": "^2.0.6", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/date-fns": "^2.5.3", + "@types/node": "^24.0.15", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "husky": "^8.0.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.6.14", + "tw-animate-css": "^1.3.5", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", + "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", + "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", + "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.3" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", + "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", + "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-arm64": "4.1.11", + "@tailwindcss/oxide-darwin-x64": "4.1.11", + "@tailwindcss/oxide-freebsd-x64": "4.1.11", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", + "@tailwindcss/oxide-linux-x64-musl": "4.1.11", + "@tailwindcss/oxide-wasm32-wasi": "4.1.11", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", + "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", + "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", + "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", + "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", + "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", + "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", + "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", + "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", + "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", + "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.11", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", + "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", + "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", + "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.11", + "@tailwindcss/oxide": "4.1.11", + "tailwindcss": "4.1.11" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz", + "integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.81.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.81.2.tgz", + "integrity": "sha512-jCeJcDCwKfoyyBXjXe9+Lo8aTkavygHHsUHAlxQKKaDeyT0qyQNLKl7+UyqYH2dDF6UN/14873IPBHchcsU+Zg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz", + "integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.83.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.83.0.tgz", + "integrity": "sha512-yfp8Uqd3I1jgx8gl0lxbSSESu5y4MO2ThOPBnGNTYs0P+ZFu+E9g5IdOngyUGuo6Uz6Qa7p9TLdZEX3ntik2fQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.81.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.83.0", + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/date-fns": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@types/date-fns/-/date-fns-2.5.3.tgz", + "integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", + "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", + "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.191", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", + "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz", + "integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz", + "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^8.3.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", + "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", + "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.7.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/sonner": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz", + "integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", + "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", + "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.6.tgz", + "integrity": "sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", + "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", + "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..c746519e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,74 @@ +{ + "name": "code-curiosity-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint --fix . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "prettier": "prettier . --ignore-path .gitignore", + "format:check": "npm run prettier -- --check", + "format:fix": "npm run prettier -- --write" + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-query-devtools": "^5.83.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.10.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^17.2.0", + "lucide-react": "^0.525.0", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-hook-form": "^7.62.0", + "react-router-dom": "^7.7.0", + "recharts": "^3.1.2", + "sonner": "^2.0.6", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.11" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/date-fns": "^2.5.3", + "@types/node": "^24.0.15", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.31.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.5.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "husky": "^8.0.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.6.14", + "tw-animate-css": "^1.3.5", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + }, + "lint-staged": { + "**/*": "prettier --write --ignore-unknown", + "**/*.{js,jsx,ts,tsx}": [ + "npm run lint" + ] + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/frontend/public/logo-cc.svg b/frontend/public/logo-cc.svg new file mode 100644 index 00000000..9ad22d63 --- /dev/null +++ b/frontend/public/logo-cc.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 00000000..894ae571 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,30 @@ + + Code Curiosity Logo + Minimal logo with coding brackets, a centered medal, and gradient colors to represent coding, open source contributions, and recognition. + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts new file mode 100644 index 00000000..418d7f3d --- /dev/null +++ b/frontend/src/api/axios.ts @@ -0,0 +1,33 @@ +import axios from "axios"; + +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { clearAccessToken, getAccessToken } from "@/shared/utils/local-storage"; +import { LOGIN_PATH } from "@/shared/constants/routes"; + +export const api = axios.create({ + baseURL: BACKEND_URL +}); + +api.interceptors.request.use( + config => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + error => Promise.reject(error) +); + +api.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + clearAccessToken(); + window.location.href = LOGIN_PATH; + } + return Promise.reject(error); + } +); + + diff --git a/frontend/src/api/queries/Admin.ts b/frontend/src/api/queries/Admin.ts new file mode 100644 index 00000000..ed6e2dd6 --- /dev/null +++ b/frontend/src/api/queries/Admin.ts @@ -0,0 +1,108 @@ +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import type { ApiResponse } from "@/shared/types/api"; +import { api } from "../axios"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + Admin, + AdminCredentials, + ContributionScore, + ContributionScoreUpdate +} from "@/shared/types/types"; +import { + CONTRIBUTION_TYPES_QUERY_KEY, + GET_ALL_USERS_QUERY_KEY +} from "@/shared/constants/query-keys"; + +const LogInAdmin = async ( + adminCredentials: AdminCredentials +): Promise> => { + const response = await api.post<{ + message: string; + data: Admin; + }>(`${BACKEND_URL}/api/v1/auth/admin`, { + email: adminCredentials.email, + password: adminCredentials.password + }); + + return response.data; +}; + +export const useLogInAdmin = () => { + return useMutation({ + mutationFn: (adminCredentials: AdminCredentials) => + LogInAdmin(adminCredentials) + }); +}; + +const getAllUsers = async (): Promise> => { + const response = await api.get<{ + message: string; + data: Admin[]; + }>(`${BACKEND_URL}/api/v1/users`); + + return response.data; +}; + +export const useGetAllUsers = () => { + return useQuery({ + queryKey: [GET_ALL_USERS_QUERY_KEY], + queryFn: getAllUsers + }); +}; + +const updateUserBlockStatus = async ( + userId: number, + block: boolean +): Promise> => { + const response = await api.patch<{ + message: string; + data: null; + }>(`${BACKEND_URL}/api/v1/users/${userId}`, { + block + }); + + return response.data; +}; + +export const useUpdateUserBlockStatus = () => { + return useMutation({ + mutationFn: ({ userId, block }: { userId: number; block: boolean }) => + updateUserBlockStatus(userId, block) + }); +}; + +const fetchContributionTypes = async (): Promise< + ApiResponse +> => { + const response = await api.get<{ + message: string; + data: ContributionScore[]; + }>(`${BACKEND_URL}/api/v1/contributions/types`); + + return response.data; +}; + +export const useFetchContributionTypes = () => { + return useQuery({ + queryKey: [CONTRIBUTION_TYPES_QUERY_KEY], + queryFn: fetchContributionTypes + }); +}; + +const configureContributionScore = async ( + contributionScore: ContributionScoreUpdate[] +): Promise> => { + const response = await api.patch<{ + message: string; + data: ContributionScore[]; + }>(`${BACKEND_URL}/api/v1/contributions/scores/configure`, contributionScore); + + return response.data; +}; + +export const useConfigureContributionScore = () => { + return useMutation({ + mutationFn: (contributionScore: ContributionScoreUpdate[]) => + configureContributionScore(contributionScore) + }); +}; diff --git a/frontend/src/api/queries/Auth.ts b/frontend/src/api/queries/Auth.ts new file mode 100644 index 00000000..3cbc3797 --- /dev/null +++ b/frontend/src/api/queries/Auth.ts @@ -0,0 +1,23 @@ +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import type { ApiResponse } from "@/shared/types/api"; +import { api } from "../axios"; +import { useQuery } from "@tanstack/react-query"; +import { GITHUB_OAUTH_LOGIN_QUERY_KEY } from "@/shared/constants/query-keys"; + +const githubOauthLogin = async (code: string): Promise> => { + const response = await api.get<{ + message: string; + data: string; + }>(`${BACKEND_URL}/api/v1/auth/github/callback?code=${code}`); + + return response.data; +}; + +export const useGithubOauthLogin = (code: string | null) => { + return useQuery({ + queryKey: [GITHUB_OAUTH_LOGIN_QUERY_KEY, code], + queryFn: () => githubOauthLogin(code!), + enabled: !!code, + retry: false, + }); +}; diff --git a/frontend/src/api/queries/Contributors.ts b/frontend/src/api/queries/Contributors.ts new file mode 100644 index 00000000..76fa8589 --- /dev/null +++ b/frontend/src/api/queries/Contributors.ts @@ -0,0 +1,25 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { Contributor } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { REPOSITORY_CONTRIBUTORS_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepositoryContributors = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Contributor[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/contributors/${repoId}`); + + return response.data; +}; + +export const useRepositoryContributors = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_CONTRIBUTORS_QUERY_KEY, repoId], + queryFn: () => fetchRepositoryContributors(repoId), + enabled: !!repoId + }); +}; diff --git a/frontend/src/api/queries/Languages.ts b/frontend/src/api/queries/Languages.ts new file mode 100644 index 00000000..3ab8ec6d --- /dev/null +++ b/frontend/src/api/queries/Languages.ts @@ -0,0 +1,24 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { Language } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { REPOSITORY_LANGUAGES_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepositoryLanguages = async ( + id: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Language[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/languages/${id}`); + + return response.data; +}; + +export const useRepositoryLanguages = (id: number) => { + return useQuery({ + queryKey: [REPOSITORY_LANGUAGES_QUERY_KEY, id], + queryFn: () => fetchRepositoryLanguages(id) + }); +}; diff --git a/frontend/src/api/queries/Leaderboard.ts b/frontend/src/api/queries/Leaderboard.ts new file mode 100644 index 00000000..e1a47cf5 --- /dev/null +++ b/frontend/src/api/queries/Leaderboard.ts @@ -0,0 +1,38 @@ +import type { LeaderboardUser } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import type { ApiResponse } from "@/shared/types/api"; +import { CURRENT_USER_RANK_QUERY_KEY, LEADERBOARD_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchLeaderboard = async (): Promise> => { + const response = await api.get<{ + message: string; + data: LeaderboardUser[]; + }>(`${BACKEND_URL}/api/v1/leaderboard`); + + return response.data; +} + +export const useLeaderboard = () => { + return useQuery({ + queryKey: [LEADERBOARD_QUERY_KEY], + queryFn: fetchLeaderboard, + }); +} + +const fetchCurrentUserRank = async (): Promise> => { + const response = await api.get<{ + message: string; + data: LeaderboardUser; + }>(`${BACKEND_URL}/api/v1/user/leaderboard`); + + return response.data; +} + +export const useCurrentUserRank = () => { + return useQuery({ + queryKey: [CURRENT_USER_RANK_QUERY_KEY], + queryFn: fetchCurrentUserRank, + }); +} \ No newline at end of file diff --git a/frontend/src/api/queries/Overview.ts b/frontend/src/api/queries/Overview.ts new file mode 100644 index 00000000..78e3bba4 --- /dev/null +++ b/frontend/src/api/queries/Overview.ts @@ -0,0 +1,32 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { Overview } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { OVERVIEW_QUERY_KEY } from "@/shared/constants/query-keys"; + +interface OverviewParams { + year: number; + month: number; +} + +const fetchOverview = async ({ year, month }: OverviewParams): Promise> => { + const response = await api.get<{ + message: string; + data: Overview[]; + }>(`${BACKEND_URL}/api/v1/user/overview`, { + params: { + year, + month + } + }); + + return response.data; +} + +export const useOverview = ({ year, month }: OverviewParams) => { + return useQuery({ + queryKey: [OVERVIEW_QUERY_KEY, year, month], + queryFn: () => fetchOverview({ year, month }), + }); +} \ No newline at end of file diff --git a/frontend/src/api/queries/RecentActivities.ts b/frontend/src/api/queries/RecentActivities.ts new file mode 100644 index 00000000..b39b50db --- /dev/null +++ b/frontend/src/api/queries/RecentActivities.ts @@ -0,0 +1,22 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { RecentActivity } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { RECENT_ACTIVITIES_QUERY_KEY } from "@/shared/constants/query-keys"; + +const fetchRecentActivities = async (): Promise> => { + const response = await api.get<{ + message: string; + data: RecentActivity[]; + }>(`${BACKEND_URL}/api/v1/user/contributions/all`); + + return response.data; +}; + +export const useRecentActivities = () => { + return useQuery({ + queryKey: [RECENT_ACTIVITIES_QUERY_KEY], + queryFn: fetchRecentActivities, + }); +} \ No newline at end of file diff --git a/frontend/src/api/queries/Repositories.ts b/frontend/src/api/queries/Repositories.ts new file mode 100644 index 00000000..b0e70a4d --- /dev/null +++ b/frontend/src/api/queries/Repositories.ts @@ -0,0 +1,22 @@ +import type { Repositories } from "@/shared/types/types"; +import { api } from "../axios"; +import type { ApiResponse } from "@/shared/types/api"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { REPOSITORIES_KEY } from "@/shared/constants/query-keys"; + +const fetchRepositories = async (): Promise> => { + const response = await api.get<{ + message: string; + data: Repositories[]; + }>(`${BACKEND_URL}/api/v1/user/repositories`); + + return response.data; +}; + +export const useRepositories = () => { + return useQuery({ + queryKey: [REPOSITORIES_KEY], + queryFn: fetchRepositories + }); +}; diff --git a/frontend/src/api/queries/Repository.tsx b/frontend/src/api/queries/Repository.tsx new file mode 100644 index 00000000..fa9434a4 --- /dev/null +++ b/frontend/src/api/queries/Repository.tsx @@ -0,0 +1,24 @@ +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import type { ApiResponse } from "@/shared/types/api"; +import type { Repository } from "@/shared/types/types"; +import { api } from "../axios"; +import { REPOSITORY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchRepository = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: Repository; + }>(`${BACKEND_URL}/api/v1/user/repositories/${repoId}`); + + return response.data; +}; + +export const useRepository = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_KEY, repoId], + queryFn: () => fetchRepository(repoId) + }); +}; diff --git a/frontend/src/api/queries/RepostoryActivities.ts b/frontend/src/api/queries/RepostoryActivities.ts new file mode 100644 index 00000000..5610284c --- /dev/null +++ b/frontend/src/api/queries/RepostoryActivities.ts @@ -0,0 +1,25 @@ +import type { ApiResponse } from "@/shared/types/api"; +import type { RepositoryActivity } from "@/shared/types/types"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useQuery } from "@tanstack/react-query"; +import { REPOSITORY_ACTIVITIES_QUERY_KEY } from "@/shared/constants/query-keys"; + +const fetchRepositoryActivivties = async ( + repoId: number +): Promise> => { + const response = await api.get<{ + message: string; + data: RepositoryActivity[]; + }>(`${BACKEND_URL}/api/v1/user/repositories/contributions/recent/${repoId}`); + + return response.data; +}; + +export const useRepositoryActivities = (repoId: number) => { + return useQuery({ + queryKey: [REPOSITORY_ACTIVITIES_QUERY_KEY, repoId], + queryFn: () => fetchRepositoryActivivties(repoId), + enabled: !!repoId + }); +}; diff --git a/frontend/src/api/queries/UserBadges.ts b/frontend/src/api/queries/UserBadges.ts new file mode 100644 index 00000000..34271e22 --- /dev/null +++ b/frontend/src/api/queries/UserBadges.ts @@ -0,0 +1,22 @@ +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { api } from "../axios"; +import type { ApiResponse } from "@/shared/types/api"; +import type { Badge } from "@/shared/types/types"; +import { USER_BADGES_QUERY_KEY } from "@/shared/constants/query-keys"; +import { useQuery } from "@tanstack/react-query"; + +const fetchUserBadges = async (): Promise> => { + const response = await api.get<{ + message: string; + data: Badge[]; + }>(`${BACKEND_URL}/api/v1/user/badges`); + + return response.data; +}; + +export const useUserBadges = () => { + return useQuery({ + queryKey: [USER_BADGES_QUERY_KEY], + queryFn: fetchUserBadges + }); +}; diff --git a/frontend/src/api/queries/UserGoals.ts b/frontend/src/api/queries/UserGoals.ts new file mode 100644 index 00000000..70fd5fdc --- /dev/null +++ b/frontend/src/api/queries/UserGoals.ts @@ -0,0 +1,139 @@ +import type { ApiResponse } from "@/shared/types/api"; +import { api } from "../axios"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + CONTRIBUTION_TYPES_QUERY_KEY, + GOAL_LEVEL_TARGETS_QUERY_KEY, + GOAL_LEVELS_QUERY_KEY, + USER_ACTIVE_GOAL_LEVEL_QUERY_KEY, + USER_GOAL_LEVEL_SUMMARY_QUERY_KEY +} from "@/shared/constants/query-keys"; +import type { + ContributionTypeDetail, + GoalLevel, + GoalLevelTarget, + GoalSummary, + SetUserGoalLevelRequest, + UserCurrentGoalStatus, + UserGoal, + UserGoalLevelStatus +} from "@/shared/types/types"; + +const fetchGoalLevels = async (): Promise> => { + const response = await api.get<{ + message: string; + data: GoalLevel[]; + }>(`${BACKEND_URL}/api/v1/goal/level`); + + return response.data; +}; + +export const useGoalLevels = () => { + return useQuery({ + queryKey: [GOAL_LEVELS_QUERY_KEY], + queryFn: fetchGoalLevels + }); +}; + +const fetchGoalLevelTargets = async ( + goalLevel: GoalLevel +): Promise> => { + const response = await api.post<{ + message: string; + data: GoalLevelTarget[]; + }>(`${BACKEND_URL}/api/v1/goal/level/targets`, goalLevel); + + return response.data; +}; + +export const useGoalLevelTargets = () => { + return useMutation({ + mutationFn: (goalLevel: GoalLevel) => fetchGoalLevelTargets(goalLevel) + }); +}; + +const fetchUserCurrentGoalStatus = async (): Promise< + ApiResponse +> => { + const response = await api.get<{ + message: string; + data: UserCurrentGoalStatus; + }>(`${BACKEND_URL}/api/v1/user/goal/level`); + + return response.data; +}; + +export const useUserCurrentGoalStatus = () => { + return useQuery({ + queryKey: [USER_ACTIVE_GOAL_LEVEL_QUERY_KEY], + queryFn: fetchUserCurrentGoalStatus + }); +}; + +const setUserGoalLevel = async ( + userGoalLevelRequest: SetUserGoalLevelRequest +): Promise> => { + const response = await api.post<{ + message: string; + data: UserGoalLevelStatus; + }>(`${BACKEND_URL}/api/v1/user/goal/level`, userGoalLevelRequest); + + return response.data; +}; + +export const useSetUserGoalLevel = () => { + return useMutation({ + mutationFn: (userGoalLevelRequest: SetUserGoalLevelRequest) => + setUserGoalLevel(userGoalLevelRequest) + }); +}; + +const resetUserGoalStatus = async (): Promise> => { + const response = await api.post<{ + message: string; + data: UserGoal; + }>(`${BACKEND_URL}/api/v1/user/goal/level/reset`); + + return response.data; +}; + +export const useResetUserGoalStatus = () => { + return useMutation({ + mutationFn: () => resetUserGoalStatus() + }); +}; + +const fetchAllContributionTypes = async (): Promise< + ApiResponse +> => { + const response = await api.get<{ + message: string; + data: ContributionTypeDetail[]; + }>(`${BACKEND_URL}/api/v1/contributions/types`); + + return response.data; +}; + +export const useAllContributionTypes = () => { + return useQuery({ + queryKey: [CONTRIBUTION_TYPES_QUERY_KEY], + queryFn: fetchAllContributionTypes + }); +}; + +const fetchUserGoalSummary = async (): Promise> => { + const response = await api.get<{ + message: string; + data: GoalSummary[]; + }>(`${BACKEND_URL}/api/v1/user/goal/summary`); + + return response.data; +}; + +export const useUserGoalSummary = () => { + return useQuery({ + queryKey: [USER_GOAL_LEVEL_SUMMARY_QUERY_KEY], + queryFn: fetchUserGoalSummary + }); +}; diff --git a/frontend/src/api/queries/UserProfileDetails.ts b/frontend/src/api/queries/UserProfileDetails.ts new file mode 100644 index 00000000..fba68ec3 --- /dev/null +++ b/frontend/src/api/queries/UserProfileDetails.ts @@ -0,0 +1,55 @@ +import type { User } from "@/shared/types/types"; +import { api } from "../axios"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { ApiResponse } from "@/shared/types/api"; +import { LOGGED_IN_USER_QUERY_KEY } from "@/shared/constants/query-keys"; +import { BACKEND_URL } from "@/shared/constants/endpoints"; + +const fetchLoggedInUser = async (): Promise> => { + const response = await api.get<{ + message: string; + data: User; + }>(`${BACKEND_URL}/api/v1/auth/user`); + + return response.data; +}; + +export const useLoggedInUser = (enabled: boolean = true) => { + return useQuery({ + queryKey: [LOGGED_IN_USER_QUERY_KEY], + queryFn: fetchLoggedInUser, + enabled, + retry: false + }); +}; + +const updateUserEmail = async (email: string): Promise> => { + const response = await api.patch<{ + message: string; + data: null; + }>(`${BACKEND_URL}/api/v1/user/email`, { + email: email + }); + + return response.data; +}; + +export const useUpdateUserEmail = () => { + return useMutation({ + mutationFn: (email: string) => updateUserEmail(email) + }); +}; + +const softDeleteUser = async (userId: number): Promise> => { + const response = await api.delete<{ message: string; data: null }>( + `${BACKEND_URL}/api/v1/user/delete/${userId}` + ); + + return response.data; +}; + +export const useSoftDeleteUser = () => { + return useMutation({ + mutationFn: (userId: number) => softDeleteUser(userId) + }); +}; diff --git a/frontend/src/api/react-query.ts b/frontend/src/api/react-query.ts new file mode 100644 index 00000000..29c17f06 --- /dev/null +++ b/frontend/src/api/react-query.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 1000 * 60 * 5 + } + } +}); diff --git a/frontend/src/assets/Coin.svg b/frontend/src/assets/Coin.svg new file mode 100644 index 00000000..476c3625 --- /dev/null +++ b/frontend/src/assets/Coin.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/bronzeBadge.svg b/frontend/src/assets/bronzeBadge.svg new file mode 100644 index 00000000..019636b3 --- /dev/null +++ b/frontend/src/assets/bronzeBadge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/coder.svg b/frontend/src/assets/coder.svg new file mode 100644 index 00000000..f056439c --- /dev/null +++ b/frontend/src/assets/coder.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/customBadge.svg b/frontend/src/assets/customBadge.svg new file mode 100644 index 00000000..a04abebc --- /dev/null +++ b/frontend/src/assets/customBadge.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/default-profile-pic.svg b/frontend/src/assets/default-profile-pic.svg new file mode 100644 index 00000000..a8d11740 --- /dev/null +++ b/frontend/src/assets/default-profile-pic.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/github-white-icon.svg b/frontend/src/assets/github-white-icon.svg new file mode 100644 index 00000000..309fecd8 --- /dev/null +++ b/frontend/src/assets/github-white-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/goldBadge.svg b/frontend/src/assets/goldBadge.svg new file mode 100644 index 00000000..04073e45 --- /dev/null +++ b/frontend/src/assets/goldBadge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/assets/information.png b/frontend/src/assets/information.png new file mode 100644 index 00000000..66ed823c Binary files /dev/null and b/frontend/src/assets/information.png differ diff --git a/frontend/src/assets/silverBadge.svg b/frontend/src/assets/silverBadge.svg new file mode 100644 index 00000000..50a59821 --- /dev/null +++ b/frontend/src/assets/silverBadge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/features/Admin/AdminLeaderboard.tsx b/frontend/src/features/Admin/AdminLeaderboard.tsx new file mode 100644 index 00000000..54fc93b4 --- /dev/null +++ b/frontend/src/features/Admin/AdminLeaderboard.tsx @@ -0,0 +1,11 @@ +import Leaderboard from "../UserDashboard/components/Leaderboard"; + +const AdminLeaderboard = () => { + return ( +
+ +
+ ); +}; + +export default AdminLeaderboard; diff --git a/frontend/src/features/Admin/AdminLogin.tsx b/frontend/src/features/Admin/AdminLogin.tsx new file mode 100644 index 00000000..a99d4d0b --- /dev/null +++ b/frontend/src/features/Admin/AdminLogin.tsx @@ -0,0 +1,101 @@ +import { type FC, useState } from "react"; +import type { AdminCredentials } from "@/shared/types/types"; +import { useLogInAdmin } from "@/api/queries/Admin"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { + ACCESS_TOKEN_KEY, + USER_DATA_KEY +} from "@/shared/constants/local-storage"; +import { Button } from "@/shared/components/ui/button"; +import { toast } from "sonner"; +import { Eye, EyeOff } from "lucide-react"; + +const AdminLogin: FC = () => { + const { register, handleSubmit } = useForm(); + const navigate = useNavigate(); + const { mutate, isPending } = useLogInAdmin(); + const [showPassword, setShowPassword] = useState(false); + + const onSubmit = async ( + data: AdminCredentials, + event?: React.BaseSyntheticEvent + ) => { + try { + event?.preventDefault(); + console.log(" Submitting admin login", data); + mutate(data, { + onSuccess: res => { + console.log(" Admin login success", res); + localStorage.setItem(ACCESS_TOKEN_KEY, res.data.jwtToken); + localStorage.setItem(USER_DATA_KEY, JSON.stringify(res.data)); + navigate("/admin/users"); + }, + onError: err => { + toast.error("Invalid credentials"); + console.error("Admin login error", err); + } + }); + } catch (err) { + console.error("Unexpected submit error", err); + } + }; + + return ( +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+ ); +}; + +export default AdminLogin; diff --git a/frontend/src/features/Admin/ScoreConfigure.tsx b/frontend/src/features/Admin/ScoreConfigure.tsx new file mode 100644 index 00000000..fd4099ce --- /dev/null +++ b/frontend/src/features/Admin/ScoreConfigure.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; + +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { + useFetchContributionTypes, + useConfigureContributionScore +} from "@/api/queries/Admin"; +import type { + ContributionScore, + ContributionScoreUpdate +} from "@/shared/types/types"; +import { toast } from "sonner"; + +const ScoreConfigure = () => { + const { data, isLoading, isError } = useFetchContributionTypes(); + const { mutate: configureScore, isPending } = useConfigureContributionScore(); + + const [scores, setScores] = useState>({}); + const [version, setVersion] = useState(0); // force re-render after save + + if (isLoading) return
Loading...
; + if (isError || !data?.data) + return
Failed to load contribution types.
; + + // Initialize state with current scores (so they are controlled) + if (Object.keys(scores).length === 0) { + const init: Record = {}; + data.data.forEach((item: ContributionScore) => { + init[item.contributionType] = String(item.score); + }); + setScores(init); + } + + const handleScoreChange = (contributionType: string, newScore: string) => { + setScores(prev => ({ ...prev, [contributionType]: newScore })); + }; + + const handleSaveAll = () => { + const invalid = Object.entries(scores).some( + ([, score]) => score === "" || isNaN(Number(score)) + ); + if (invalid) { + toast.error("All scores must be valid numbers (no empty fields)."); + return; + } + + const updates: ContributionScoreUpdate[] = Object.entries(scores).map( + ([contributionType, score]) => ({ + contributionType, + score: Number(score) + }) + ); + + configureScore(updates, { + onSuccess: () => { + toast.success("Contribution scores updated successfully."); + // trigger re-render to update sorted order + setVersion(prev => prev + 1); + }, + onError: () => { + toast.error("Failed to update contribution scores."); + console.log(updates); + } + }); + }; + + // Sort data based on current scores + const sortedData = [...data.data].sort( + (a, b) => + Number(scores[a.contributionType]) - Number(scores[b.contributionType]) + ); + + return ( +
+
+

+ Configure Contribution Scores +

+
+ +
+ {sortedData.map((item: ContributionScore) => ( +
+
+ {item.contributionType.replace(/([a-z])([A-Z])/g, "$1 $2")} +
+ + + handleScoreChange(item.contributionType, e.target.value) + } + /> +
+ ))} +
+ +
+ +
+
+ ); +}; + +export default ScoreConfigure; diff --git a/frontend/src/features/Admin/Users.tsx b/frontend/src/features/Admin/Users.tsx new file mode 100644 index 00000000..9725e630 --- /dev/null +++ b/frontend/src/features/Admin/Users.tsx @@ -0,0 +1,394 @@ +import { type FC, useEffect, useState } from "react"; +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable +} from "@tanstack/react-table"; +import defaultAvatar from "@/assets/default-profile-pic.svg"; +import { + Loader, + User, + Shield, + ShieldOff, + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Search, + X +} from "lucide-react"; +import { useGetAllUsers, useUpdateUserBlockStatus } from "@/api/queries/Admin"; +import Coin from "@/shared/components/common/Coin"; +import { toast } from "sonner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/shared/components/ui/table"; +import { Button } from "@/shared/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/shared/components/ui/select"; +import { Input } from "@/shared/components/ui/input"; + +interface User { + userId: number; + githubUsername: string; + avatarUrl?: string; + currentBalance: number; + isBlocked: boolean; +} + +export const AllUsersList: FC = () => { + const { data, isLoading } = useGetAllUsers(); + const { mutate: updateBlockStatus } = useUpdateUserBlockStatus(); + const [users, setUsers] = useState(data?.data || []); + + useEffect(() => { + if (data?.data) { + setUsers(data.data); + } + }, [data]); + + const handleBlockToggle = (userId: number, block: boolean) => { + updateBlockStatus( + { userId, block }, + { + onSuccess: () => { + setUsers(prev => + prev.map(user => + user.userId === userId ? { ...user, isBlocked: block } : user + ) + ); + toast.success( + `User has been ${block ? "blocked" : "unblocked"} successfully.` + ); + }, + onError: () => { + toast.error("Something went wrong while updating block status."); + } + } + ); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "githubUsername", + header: "User", + cell: ({ row }) => { + const user = row.original; + return ( +
+ User Avatar + + {user.githubUsername} + +
+ ); + } + }, + { + accessorKey: "currentBalance", + header: "Balance", + cell: ({ row }) => { + return ( +
+ + {row.getValue("currentBalance")} +
+ ); + } + }, + { + accessorKey: "isBlocked", + header: "Status", + cell: ({ row }) => { + const isBlocked = row.getValue("isBlocked") as boolean; + return ( + + {isBlocked ? ( + <> + + Blocked + + ) : ( + <> + + Active + + )} + + ); + } + }, + { + id: "actions", + header: () =>
Actions
, + cell: ({ row }) => { + const user = row.original; + return ( +
+ +
+ ); + } + } + ]; + + const table = useReactTable({ + data: users, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 10 + } + } + }); + + if (isLoading) { + return ( +
+ + Loading users... +
+ ); + } + + if (!users.length) { + return ( +
+
+ +

No users found

+
+
+ ); + } + + return ( +
+
+

+ User Management +

+
+

+ Showing{" "} + {table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + 1} + - + {Math.min( + (table.getState().pagination.pageIndex + 1) * + table.getState().pagination.pageSize, + table.getFilteredRowModel().rows.length + )}{" "} + of {table.getFilteredRowModel().rows.length} users + {table.getState().globalFilter && + ` (filtered from ${users.length} total)`} +

+
+
+ +
+
+ + table.setGlobalFilter(e.target.value)} + className="pl-8" + /> + {table.getState().globalFilter && ( + + )} +
+ + +
+ +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + {/* Pagination */} +
+
+

Rows per page

+ +
+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} +
+
+ + + + +
+
+
+
+ ); +}; diff --git a/frontend/src/features/Login/components/LoginComponent.tsx b/frontend/src/features/Login/components/LoginComponent.tsx new file mode 100644 index 00000000..4d26387a --- /dev/null +++ b/frontend/src/features/Login/components/LoginComponent.tsx @@ -0,0 +1,78 @@ +import { Button } from "@/shared/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader +} from "@/shared/components/ui/card"; +import { GITHUB_AUTH_URL } from "@/shared/constants/endpoints"; +import Coder from "@/assets/coder.svg"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect } from "react"; +import { setAccessToken } from "@/shared/utils/local-storage"; +import { useGithubOauthLogin } from "@/api/queries/Auth"; +import { toast } from "sonner"; +import githubIcon from "@/assets/github-white-icon.svg"; +const LoginComponent = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const code = searchParams.get("code"); + console.log("code", code); + const { data, isSuccess, isError } = useGithubOauthLogin(code); + console.log("data", data); + console.log("isSuccess", isSuccess); + useEffect(() => { + if (!code) return; + + if (isSuccess && data?.data) { + const token = data.data; + setAccessToken(token); + + console.log("hello"); + navigate("/"); + } + + if (isError) { + toast.error("OAuth login failed:"); + } + }, [isSuccess, isError, data, navigate]); + + const handleGithubLogin = () => { + window.location.href = GITHUB_AUTH_URL || ""; + }; + + return ( + + + Developer Illustration + + + + +
+
+ + +

+ No idea where to start? Try this{" "} + + Code Triage + +

+
+
+ ); +}; + +export default LoginComponent; diff --git a/frontend/src/features/Login/index.tsx b/frontend/src/features/Login/index.tsx new file mode 100644 index 00000000..e254c720 --- /dev/null +++ b/frontend/src/features/Login/index.tsx @@ -0,0 +1,7 @@ +import LoginComponent from "@/features/Login/components/LoginComponent"; + +const Login = () => { + return ; +}; + +export default Login; diff --git a/frontend/src/features/MyContributions/components/Repositories.tsx b/frontend/src/features/MyContributions/components/Repositories.tsx new file mode 100644 index 00000000..fbfc5aec --- /dev/null +++ b/frontend/src/features/MyContributions/components/Repositories.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import { useRepositories } from "@/api/queries/Repositories"; +import { Separator } from "@/shared/components/ui/separator"; +import RepositoriesCard from "./RepositoriesCard"; + +const Repositories = () => { + const { data, isLoading } = useRepositories(); + const repositoriesData = data?.data || []; + + const [search, setSearch] = useState(""); + const [language, setLanguage] = useState("All"); + const [sort, setSort] = useState("latest"); + + if (isLoading) + return ( +
+
+
+ ); + + const filteredRepos = repositoriesData + .filter( + repo => + repo.repoName.toLowerCase().includes(search.toLowerCase()) || + repo.description?.toLowerCase().includes(search.toLowerCase()) || + repo.languages?.some((lang: string) => + lang.toLowerCase().includes(search.toLowerCase()) + ) + ) + .filter(repo => + language === "All" ? true : repo.languages?.includes(language) + ) + .sort((a, b) => { + if (sort === "latest") + return ( + new Date(b.updateDate).getTime() - new Date(a.updateDate).getTime() + ); + if (sort === "oldest") + return ( + new Date(a.updateDate).getTime() - new Date(b.updateDate).getTime() + ); + return 0; + }); + + return ( +
+
+ setSearch(e.target.value)} + className="border-cc-app-blue text-cc-app-blue placeholder-cc-app-blue focus:border-cc-app-blue focus:ring-cc-app-blue/70 h-10 flex-1 rounded-lg border bg-blue-50 px-4 text-sm shadow-sm transition focus:ring-2 focus:outline-none" + /> + +
+ + + +
+
+ + {filteredRepos.length === 0 ? ( +
+

+ No repositories found +

+

+ Try adjusting your search or filters +

+
+ ) : ( + filteredRepos.map(repo => ( +
+ + +
+ )) + )} +
+ ); +}; + +export default Repositories; diff --git a/frontend/src/features/MyContributions/components/RepositoriesCard.tsx b/frontend/src/features/MyContributions/components/RepositoriesCard.tsx new file mode 100644 index 00000000..266df629 --- /dev/null +++ b/frontend/src/features/MyContributions/components/RepositoriesCard.tsx @@ -0,0 +1,65 @@ +import Coin from "@/shared/components/common/Coin"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { LangColor } from "@/shared/constants/constants"; +import { format } from "date-fns"; +import type { FC } from "react"; +import { useNavigate } from "react-router-dom"; + +interface RepositoriesCardProps { + id: number; + name: string; + languages: string[]; + description: string; + updatedOn: string; + coins: number; +} + +const RepositoriesCard: FC = ({ + id, + name, + languages, + description, + updatedOn, + coins +}) => { + const navigate = useNavigate(); + + return ( + + +
navigate(`/repositories/${id}`)} + > +

{name}

+
+ + {coins} +
+
+ +
+ {languages?.map((language, index) => { + const color = LangColor[language] || "bg-gray-400"; + return ( +
+
+ {language} +
+ ); + })} +
+ +

+ {description || "No description for the given repository"} +

+ +

+ Updated on {format(new Date(updatedOn), "MMM d, yyyy")} +

+
+
+ ); +}; + +export default RepositoriesCard; diff --git a/frontend/src/features/MyContributions/index.tsx b/frontend/src/features/MyContributions/index.tsx new file mode 100644 index 00000000..38bce587 --- /dev/null +++ b/frontend/src/features/MyContributions/index.tsx @@ -0,0 +1,13 @@ +import Repositories from "./components/Repositories"; + +const MyContributions = () => { + return ( +
+
+ +
+
+ ); +}; + +export default MyContributions; diff --git a/frontend/src/features/RepositoryDetails/components/Contributors.tsx b/frontend/src/features/RepositoryDetails/components/Contributors.tsx new file mode 100644 index 00000000..4f2325cf --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/Contributors.tsx @@ -0,0 +1,64 @@ +import { useRepositoryContributors } from "@/api/queries/Contributors"; +import { useParams } from "react-router-dom"; +import ContributorsCard from "./ContributorsCard"; +import { useState } from "react"; +import { Button } from "@/shared/components/ui/button"; +import clsx from "clsx"; + +const ContributorsList = () => { + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data, isLoading } = useRepositoryContributors(repoId); + const contributors = data?.data ?? []; + + const contributorsData = viewAll ? contributors : contributors?.slice(0, 20); + + return ( +
+
+

+ Contributors {contributors.length} +

+ +
+ {isLoading ? ( +
+
+
+ ) : ( +
+
+ {contributorsData?.map(contributor => ( + + ))} +
+
+ )} +
+ ); +}; + +export default ContributorsList; diff --git a/frontend/src/features/RepositoryDetails/components/ContributorsCard.tsx b/frontend/src/features/RepositoryDetails/components/ContributorsCard.tsx new file mode 100644 index 00000000..56428ac9 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/ContributorsCard.tsx @@ -0,0 +1,40 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"; +import type { FC } from "react"; +import { Link } from "react-router-dom"; + +interface ContributorsCardProps { + name: string; + avatarUrl: string; + contributions: number; + githubUrl: string; +} + +const ContributorsCard: FC = ({ + name, + avatarUrl, + contributions, + githubUrl +}) => { + return ( +
+ + + + Contributors-Image + + {/* Tooltip */} +
+

{name}

+

{contributions} Contributions

+
+ +
+
+ ); +}; + +export default ContributorsCard; diff --git a/frontend/src/features/RepositoryDetails/components/Languages.tsx b/frontend/src/features/RepositoryDetails/components/Languages.tsx new file mode 100644 index 00000000..d47572f6 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/Languages.tsx @@ -0,0 +1,26 @@ +import { type FC } from "react"; +import { useRepositoryLanguages } from "@/api/queries/Languages"; +import { useParams } from "react-router-dom"; +import LanguageCard from "./LanguagesCard"; + +interface LanguagesProps { + className?: string; +} + +const Languages: FC = ({ className }) => { + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data } = useRepositoryLanguages(repoId); + + const languagesData = data?.data; + return ( + + ); +}; + +export default Languages; +export type { LanguagesProps }; diff --git a/frontend/src/features/RepositoryDetails/components/LanguagesCard.tsx b/frontend/src/features/RepositoryDetails/components/LanguagesCard.tsx new file mode 100644 index 00000000..15d0fde2 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/LanguagesCard.tsx @@ -0,0 +1,61 @@ +import { type FC } from "react"; +import clsx from "clsx"; +import type { Language } from "@/shared/types/types"; +import { LangColor } from "@/shared/constants/constants"; + +interface LanguageCardProps { + title?: string; + languages: Language[]; + className?: string; +} + +const LanguageCard: FC = ({ + title = "Languages", + languages, + className +}) => { + return ( +
+

{title}

+ +
+ {languages.map((language, index) => { + console.log(language.name); + const bgColor = LangColor[language.name] || "bg-gray-400"; + return ( +
+ ); + })} +
+ +
+ {languages.map((language, index) => { + const color = LangColor[language.name] || "bg-gray-400"; + return ( +
+
+ + {language.name} {language.percentage}% + +
+ ); + })} +
+
+ ); +}; + +export default LanguageCard; +export type { LanguageCardProps }; diff --git a/frontend/src/features/RepositoryDetails/components/Repository.tsx b/frontend/src/features/RepositoryDetails/components/Repository.tsx new file mode 100644 index 00000000..fc5f4cb6 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/Repository.tsx @@ -0,0 +1,26 @@ +import RepositoryCard from "./RepositoryCard"; +import { useRepository } from "@/api/queries/Repository"; +import { useParams } from "react-router-dom"; + +const Repository = () => { + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data } = useRepository(repoId); + const repo = data?.data; + + return ( +
+ +
+ ); +}; + +export default Repository; diff --git a/frontend/src/features/RepositoryDetails/components/RepositoryActivities.tsx b/frontend/src/features/RepositoryDetails/components/RepositoryActivities.tsx new file mode 100644 index 00000000..06d3e9f9 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/RepositoryActivities.tsx @@ -0,0 +1,98 @@ +import { useState, type FC } from "react"; +import clsx from "clsx"; +import { Button } from "@/shared/components/ui/button"; +import { Card } from "@/shared/components/ui/card"; +import ActivityCard from "@/shared/components/common/ActivityCard"; +import { useParams } from "react-router-dom"; +import { TrendingUp } from "lucide-react"; +import { useRepositoryActivities } from "@/api/queries/RepostoryActivities"; +import CoinsInfo from "@/shared/components/common/CoinsInfo"; + +interface RepositoryActivitiesProps { + className?: string; +} + +const RepositoryActivities: FC = ({ className }) => { + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { repoid } = useParams(); + const repoId = Number(repoid); + const { data, isLoading } = useRepositoryActivities(repoId); + const repositoryActivities = data?.data ?? []; + const repositoryActivitiesData = viewAll + ? repositoryActivities + : repositoryActivities?.slice(0, 4); + + let content; + if (isLoading) { + content = ( +
+
+
+ ); + } else if (repositoryActivitiesData?.length === 0) { + content = ( +
+ +

+ No recent activities found +

+
+ ); + } else { + content = ( +
+ {repositoryActivitiesData?.map((activity, index) => ( + + ))} + {!viewAll && ( +
+ +
+ )} +
+ ); + } + + return ( + +
+

Recent Activities

+ +
+ {content} +
+ ); +}; + +export default RepositoryActivities; diff --git a/frontend/src/features/RepositoryDetails/components/RepositoryCard.tsx b/frontend/src/features/RepositoryDetails/components/RepositoryCard.tsx new file mode 100644 index 00000000..04945440 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/components/RepositoryCard.tsx @@ -0,0 +1,65 @@ +import { LangColor } from "@/shared/constants/constants"; +import { ExternalLink } from "lucide-react"; +import type { FC } from "react"; + +interface RepositoriesCardProps { + name: string; + languages: string[]; + description: string; + updatedOn: string; + owner: string; + repoUrl: string; +} + +const RepositoryCard: FC = ({ + name, + languages, + description, + owner, + repoUrl +}) => { + return ( +
+
+ +
+ {name} + + +
+
+ +

+ Owned By: {owner} +

+
+ +
+ {languages?.map((language, index) => { + const color = LangColor[language] || "bg-gray-400"; + return ( +
+
+ {language} +
+ ); + })} +
+ +

+ {description || + "No description for the given repository. Lorem ipsum dolor sit amet."} +

+
+ ); +}; + +export default RepositoryCard; diff --git a/frontend/src/features/RepositoryDetails/index.tsx b/frontend/src/features/RepositoryDetails/index.tsx new file mode 100644 index 00000000..85dcfce5 --- /dev/null +++ b/frontend/src/features/RepositoryDetails/index.tsx @@ -0,0 +1,37 @@ +import Repository from "./components/Repository"; +import Languages from "./components/Languages"; +import RecentActivities from "./components/RepositoryActivities"; +import ContributorsList from "./components/Contributors"; +import { ArrowLeft } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { Separator } from "@/shared/components/ui/separator"; + +const RepositoryDetails = () => { + const navigate = useNavigate(); + return ( +
+
navigate("/my-contributions")} + > + + + Repository Details + +
+
+
+ + + +
+
+ + +
+
+
+ ); +}; + +export default RepositoryDetails; diff --git a/frontend/src/features/UserDashboard/components/Leaderboard.tsx b/frontend/src/features/UserDashboard/components/Leaderboard.tsx new file mode 100644 index 00000000..86900d64 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/Leaderboard.tsx @@ -0,0 +1,94 @@ +import { useState, type FC } from "react"; +import clsx from "clsx"; + +import { Button } from "@/shared/components/ui/button"; +import { Card } from "@/shared/components/ui/card"; +import LeaderboardCard from "@/features/UserDashboard/components/LeaderboardCard"; +import { useCurrentUserRank, useLeaderboard } from "@/api/queries/Leaderboard"; +import { TrendingUp } from "lucide-react"; +import { useLocation } from "react-router-dom"; +import { ADMIN_LEADERBOARD_PATH } from "@/shared/constants/routes"; + +interface LeaderboardProps { + className?: string; +} + +const Leaderboard: FC = ({ className }) => { + let isAdmin = false; + const location = useLocation(); + if (location.pathname == ADMIN_LEADERBOARD_PATH) { + isAdmin = true; + } + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { data, isLoading } = useLeaderboard(); + const leaderboard = data?.data ?? []; + + const leaderboardData = viewAll ? leaderboard : leaderboard?.slice(0, 10); + + const { data: userData } = useCurrentUserRank(); + const currentUser = userData?.data; + + return ( + +
+

Leader Board

+ +
+ + {isLoading ? ( +
+
+
+ ) : leaderboardData?.length === 0 ? ( +
+ +

+ No leaderboard data +

+
+ ) : ( + <> +
+ {leaderboardData?.map(user => ( + + ))} +
+ {!viewAll && !isAdmin && ( +
+ +
+ )} + + )} +
+ ); +}; + +export default Leaderboard; diff --git a/frontend/src/features/UserDashboard/components/LeaderboardCard.tsx b/frontend/src/features/UserDashboard/components/LeaderboardCard.tsx new file mode 100644 index 00000000..f2171616 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/LeaderboardCard.tsx @@ -0,0 +1,46 @@ +import type { FC } from "react"; + +import Coin from "@/shared/components/common/Coin"; +import { Card, CardContent } from "@/shared/components/ui/card"; + +interface LeaderboardCardProps { + rank: number; + username: string; + repositories: number; + balance: number; +} + +const LeaderboardCard: FC = ({ + rank, + username, + repositories, + balance +}) => { + return ( +
+ + +
+ {rank} +
+
+
{username}
+
+ Contributed to{" "} + + {repositories}{" "} + {repositories > 1 ? "Repositories" : "Repository"} + +
+
+
+ + {balance} +
+
+
+
+ ); +}; + +export default LeaderboardCard; \ No newline at end of file diff --git a/frontend/src/features/UserDashboard/components/Overview.tsx b/frontend/src/features/UserDashboard/components/Overview.tsx new file mode 100644 index 00000000..e5fb648f --- /dev/null +++ b/frontend/src/features/UserDashboard/components/Overview.tsx @@ -0,0 +1,113 @@ +import { useState, type FC } from "react"; +import clsx from "clsx"; +import { Card } from "@/shared/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@/shared/components/ui/dropdown-menu"; +import OverviewCard from "@/features/UserDashboard/components/OverviewCard"; +import { ChevronDown, TrendingUp } from "lucide-react"; +import { format, subMonths } from "date-fns"; +import { useOverview } from "@/api/queries/Overview"; + +interface OverviewProps { + className?: string; +} + +const getLastNMonths = (n: number) => { + return Array.from({ length: n }).map((_, i) => { + const date = subMonths(new Date(), i); + return { + label: format(date, "MMMM yyyy"), + month: date.getMonth() + 1, + year: date.getFullYear() + }; + }); +}; + +const Overview: FC = ({ className }) => { + const monthOptions = getLastNMonths(3); + const [selectedPeriod, setSelectedPeriod] = useState<{ + month: number; + year: number; + }>(monthOptions[0]); + + const { data, isLoading } = useOverview(selectedPeriod); + const overview = data?.data ?? []; + + const overviewData = overview?.filter(data => { + const date = new Date(data.month); + return ( + date.getFullYear() === selectedPeriod.year && + date.getMonth() + 1 === selectedPeriod.month + ); + }); + + return ( + +
+

Overview

+ + + {monthOptions.find( + opt => + opt.month === selectedPeriod.month && + opt.year === selectedPeriod.year + )?.label ?? "Select Month"} + + + + {monthOptions.map(option => ( + + setSelectedPeriod({ month: option.month, year: option.year }) + } + className={clsx( + "cursor-pointer rounded-sm px-3 py-2 text-sm hover:bg-gray-100", + selectedPeriod.month === option.month && + selectedPeriod.year === option.year && + "bg-cc-app-gray-background" + )} + > + {option.label} + + ))} + + +
+ {isLoading ? ( +
+
+
+ ) : overviewData?.length === 0 ? ( +
+ +

+ No overview data for the selected period. +

+
+ ) : ( +
+ {overviewData?.map(user => ( + + ))} +
+ )} +
+ ); +}; + +export default Overview; diff --git a/frontend/src/features/UserDashboard/components/OverviewCard.tsx b/frontend/src/features/UserDashboard/components/OverviewCard.tsx new file mode 100644 index 00000000..a0652298 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/OverviewCard.tsx @@ -0,0 +1,34 @@ +import { type FC } from "react"; +import Coin from "@/shared/components/common/Coin"; +import { Card } from "@/shared/components/ui/card"; + +interface OverviewCardProps { + type: string; + count: number; + totalCoins: number; +} + +const OverviewCard: FC = ({ type, count, totalCoins }) => { + return ( + +
+

+ {type.replace(/([A-Z])/g, " $1")} +

+
+ + {count} + +
+ + + {totalCoins} + +
+
+
+
+ ); +}; + +export default OverviewCard; diff --git a/frontend/src/features/UserDashboard/components/RecentActivities.tsx b/frontend/src/features/UserDashboard/components/RecentActivities.tsx new file mode 100644 index 00000000..ab0366e7 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/RecentActivities.tsx @@ -0,0 +1,85 @@ +import { useState, type FC } from "react"; +import clsx from "clsx"; +import { Button } from "@/shared/components/ui/button"; +import { Card } from "@/shared/components/ui/card"; +import ActivityCard from "@/shared/components/common/ActivityCard"; +import { useRecentActivities } from "@/api/queries/RecentActivities"; +import { TrendingUp } from "lucide-react"; +import CoinsInfo from "@/shared/components/common/CoinsInfo"; + +interface RecentActivitiesProps { + className?: string; +} + +const RecentActivities: FC = ({ className }) => { + const [viewAll, setViewAll] = useState(false); + + const handleViewAll = () => { + setViewAll(!viewAll); + }; + + const { data, isLoading } = useRecentActivities(); + const recentActivities = data?.data ?? []; + const recentActivitiesData = viewAll + ? recentActivities + : recentActivities?.slice(0, 4); + + return ( + +
+

Recent Activities

+ +
+ + {isLoading ? ( +
+
+
+ ) : recentActivitiesData?.length === 0 ? ( +
+ +

+ No recent activities found +

+
+ ) : ( +
+ {recentActivitiesData?.map((activity, index) => ( + + ))} + {!viewAll && ( +
+ +
+ )} +
+ )} +
+ ); +}; + +export default RecentActivities; diff --git a/frontend/src/features/UserDashboard/components/UserDashboardComponent.tsx b/frontend/src/features/UserDashboard/components/UserDashboardComponent.tsx new file mode 100644 index 00000000..8943e036 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/UserDashboardComponent.tsx @@ -0,0 +1,23 @@ +import Leaderboard from "@/features/UserDashboard/components/Leaderboard"; +import RecentActivities from "@/features/UserDashboard/components/RecentActivities"; +import Overview from "@/features/UserDashboard/components/Overview"; +import UserGoalSummaryChart from "./UserGoalSummary"; + +const UserDashboardComponent = () => { + return ( +
+
+ +
+ + +
+
+
+ +
+
+ ); +}; + +export default UserDashboardComponent; diff --git a/frontend/src/features/UserDashboard/components/UserGoalSummary.tsx b/frontend/src/features/UserDashboard/components/UserGoalSummary.tsx new file mode 100644 index 00000000..2d668f39 --- /dev/null +++ b/frontend/src/features/UserDashboard/components/UserGoalSummary.tsx @@ -0,0 +1,69 @@ +import { useUserGoalSummary } from "@/api/queries/UserGoals"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer +} from "recharts"; + +const UserGoalSummaryChart = () => { + const { data, isLoading, isError } = useUserGoalSummary(); + + if (isLoading) return
Loading...
; + if (!data) + return ( +
+ Please set goals to view goal summary. Goal summary is update after 24 + hours. +
+ ); + if (isError) return
Error loading goal summary
; + + const summaryData = data.data; + const chartData = summaryData.map(item => ({ + day: new Date(item.snapshotDate).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short" + }), + incomplete: item.incompleteGoalsCount, + targetSet: item.targetSet, + targetCompleted: item.targetCompleted + })); + + return ( + + + + + + + + + + + + + + ); +}; + +export default UserGoalSummaryChart; diff --git a/frontend/src/features/UserDashboard/index.tsx b/frontend/src/features/UserDashboard/index.tsx new file mode 100644 index 00000000..554ee80e --- /dev/null +++ b/frontend/src/features/UserDashboard/index.tsx @@ -0,0 +1,7 @@ +import UserDashboardComponent from "@/features/UserDashboard/components/UserDashboardComponent"; + +const UserDashboard = () => { + return ; +}; + +export default UserDashboard; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 00000000..91621762 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,174 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* Define custom colors as CSS variables */ +@theme { + --color-vite-blue: #000000; + --color-vite-purple: #535bf2; + --color-vite-dark: #242424; + --color-vite-dark-light: #1a1a1a; + --color-primary-brown: #a0522d; +} + +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --cc-app-light-blue: oklch(0.6686 0.1358 231.66); + --cc-app-sky-blue: oklch(0.6163 0.140573 239.7492); + --cc-app-mid-blue: oklch(0.4668 0.1625 256.62); + --cc-app-blue: oklch(0.3876 0.1761 261.76); + --cc-app-gray-background: oklch(0.9585 0.0195 270.21); + --cc-app-orange: oklch(0.7362 0.1641 62.07); + --cc-app-yellow: oklch(0.9136 0.174 99.92); +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-acry: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --color-cc-app-light-blue: var(--cc-app-light-blue); + --color-cc-app-sky-blue: var(--cc-app-sky-blue); + --color-cc-app-mid-blue: var(--cc-app-mid-blue); + --color-cc-app-blue: var(--cc-app-blue); + --color-cc-app-gray-background: var(--cc-app-gray-background); + --color-cc-app-orange: var(--cc-app-orange); + --color-cc-app-yellow: var(--cc-app-yellow); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + body { + @apply bg-background text-foreground; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } + + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 00000000..cd015264 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; + +import Router from "@/root/Router"; +import { queryClient } from "@/api/react-query.ts"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + + +); diff --git a/frontend/src/root/Router.tsx b/frontend/src/root/Router.tsx new file mode 100644 index 00000000..ccd09460 --- /dev/null +++ b/frontend/src/root/Router.tsx @@ -0,0 +1,44 @@ +import { RouterProvider, createBrowserRouter } from "react-router-dom"; + +import WithAuth from "@/shared/HOC/WithAuth"; +import { type RoutesType, routesConfig } from "@/root/routes-config"; +import { Layout } from "@/shared/constants/layout"; +import AuthLayout from "@/shared/layout/AuthLayout"; +import UserDashboardLayout from "@/shared/layout/UserDashboardLayout"; +import AdminLayout from "@/shared/layout/AdminLayout"; +import AppLayout from "@/shared/layout/AppLayout"; + +const generateRoutes = (routes: RoutesType[]) => { + return routes.map(({ path, element, isProtected, layout }) => { + let wrappedElement = element; + + + if (isProtected) { + wrappedElement = {wrappedElement}; + } + + if (layout == Layout.AuthLayout) { + wrappedElement = {wrappedElement}; + } + + if (layout == Layout.DashboardLayout) { + wrappedElement = ( + {wrappedElement} + ); + } + + if (layout == Layout.AdminLayout) { + wrappedElement = {wrappedElement}; + } + wrappedElement = {wrappedElement}; + + return { path, element: wrappedElement }; + }); +}; + +const Router = () => { + const router = createBrowserRouter(generateRoutes(routesConfig)); + return ; +}; + +export default Router; diff --git a/frontend/src/root/routes-config.tsx b/frontend/src/root/routes-config.tsx new file mode 100644 index 00000000..15e567f0 --- /dev/null +++ b/frontend/src/root/routes-config.tsx @@ -0,0 +1,86 @@ +import type { ReactNode } from "react"; +import { Layout, type LayoutType } from "@/shared/constants/layout"; +import Login from "@/features/Login"; +import MyContributions from "@/features/MyContributions"; +import UserDashboard from "@/features/UserDashboard"; +import { + ACCOUNT_INFO_PATH, + ADMIN_LEADERBOARD_PATH, + ADMIN_LOGIN_PATH, + ADMIN_SCORE_CONFIGURE_PATH, + ADMIN_USERS_PATH, + LOGIN_PATH, + MY_CONTRIBUTIONS_PATH, + REPOSITORY_DETAILS_PATH, + USER_DASHBOARD_PATH +} from "@/shared/constants/routes"; +import AdminLogin from "@/features/Admin/AdminLogin"; +import { AllUsersList } from "@/features/Admin/Users.tsx"; +import ScoreConfigure from "@/features/Admin/ScoreConfigure"; +import BlockedAccountPage from "@/shared/components/common/BlockedAccountPage"; +import RepositoryDetails from "@/features/RepositoryDetails"; +import Leaderboard from "@/features/UserDashboard/components/Leaderboard"; +import AdminLeaderboard from "@/features/Admin/AdminLeaderboard"; +export interface RoutesType { + path: string; + element: ReactNode; + isProtected?: boolean; + layout: LayoutType; +} + +export const routesConfig: RoutesType[] = [ + { + path: LOGIN_PATH, + element: , + isProtected: false, + layout: Layout.AuthLayout + }, + { + path: USER_DASHBOARD_PATH, + element: , + isProtected: true, + layout: Layout.DashboardLayout + }, + { + path: MY_CONTRIBUTIONS_PATH, + element: , + isProtected: true, + layout: Layout.DashboardLayout + }, + { + path: REPOSITORY_DETAILS_PATH, + element: , + isProtected: true, + layout: Layout.DashboardLayout + }, + { + path: ADMIN_LOGIN_PATH, + element: , + isProtected: false, + layout: Layout.AuthLayout + }, + { + path: ADMIN_USERS_PATH, + element: , + isProtected: true, + layout: Layout.AdminLayout + }, + { + path: ADMIN_SCORE_CONFIGURE_PATH, + element: , + isProtected: true, + layout: Layout.AdminLayout + }, + { + path: ADMIN_LEADERBOARD_PATH, + element: , + isProtected: true, + layout: Layout.AdminLayout + }, + { + path: ACCOUNT_INFO_PATH, + element: , + isProtected: false, + layout: Layout.None + } +]; diff --git a/frontend/src/shared/HOC/WithAuth.tsx b/frontend/src/shared/HOC/WithAuth.tsx new file mode 100644 index 00000000..171ba7e9 --- /dev/null +++ b/frontend/src/shared/HOC/WithAuth.tsx @@ -0,0 +1,29 @@ +import { type FC, type ReactNode, useEffect } from "react"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; + +import { LOGIN_PATH } from "@/shared/constants/routes"; +import { getAccessToken } from "@/shared/utils/local-storage"; + +interface WithAuthProps { + children: ReactNode; +} + +const WithAuth: FC = ({ children }) => { + const navigate = useNavigate(); + const location = useLocation(); + const userAccessToken = getAccessToken(); + + useEffect(() => { + if (!userAccessToken) { + navigate(LOGIN_PATH, { replace: true }); + } + }, [userAccessToken, location.pathname, navigate]); + + if (!userAccessToken) { + return ; + } + + return <>{children}; +}; + +export default WithAuth; \ No newline at end of file diff --git a/frontend/src/shared/components/AdminDashboard/AdminProfile.tsx b/frontend/src/shared/components/AdminDashboard/AdminProfile.tsx new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/shared/components/UserDashboard/DeleteAccount.tsx b/frontend/src/shared/components/UserDashboard/DeleteAccount.tsx new file mode 100644 index 00000000..8ab0c35d --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/DeleteAccount.tsx @@ -0,0 +1,69 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription +} from "@/shared/components/ui/dialog"; +import { Button } from "@/shared/components/ui/button"; +import { + useLoggedInUser, + useSoftDeleteUser +} from "@/api/queries/UserProfileDetails"; +import { clearAccessToken } from "@/shared/utils/local-storage"; +import { useNavigate } from "react-router-dom"; + +interface Props { + open: boolean; + onClose: () => void; +} + +const DeleteAccount = ({ open, onClose }: Props) => { + const navigate = useNavigate(); + const { data } = useLoggedInUser(); + const user = data?.data; + const { mutate: softDeleteUser, isPending } = useSoftDeleteUser(); + + const handleDelete = () => { + if (!user?.userId) return; + softDeleteUser(user.userId, { + onSuccess: () => { + onClose(); + clearAccessToken(); + navigate("/login"); + } + }); + }; + + return ( + + + + Delete Account + + Deleting your account will not immediately erase your data. We will + retain your information for 3 months in case you choose to return. + After that period, your data will be permanently removed from our + platform. + + + + + + + + + + ); +}; + +export default DeleteAccount; diff --git a/frontend/src/shared/components/UserDashboard/Navbar.tsx b/frontend/src/shared/components/UserDashboard/Navbar.tsx new file mode 100644 index 00000000..b660ce34 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/Navbar.tsx @@ -0,0 +1,30 @@ +import { Link, useLocation } from "react-router-dom"; + +import { Card } from "@/shared/components/ui/card"; +import { USER_DASHBOARD_NAVBAR_OPTIONS } from "@/shared/types/navbar"; + +const Navbar = () => { + const location = useLocation(); + + const isActive = (path: string) => location.pathname === path; + + return ( + + {USER_DASHBOARD_NAVBAR_OPTIONS.map(option => ( + + {option.name} + + ))} + + ); +}; + +export default Navbar; diff --git a/frontend/src/shared/components/UserDashboard/SettingsDialog.tsx b/frontend/src/shared/components/UserDashboard/SettingsDialog.tsx new file mode 100644 index 00000000..e71011de --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/SettingsDialog.tsx @@ -0,0 +1,65 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/shared/components/ui/dialog"; +import { Button } from "@/shared/components/ui/button"; +import { useState } from "react"; +import DeleteAccount from "./DeleteAccount"; + +interface Props { + open: boolean; + onClose: () => void; + onUpdateEmail: () => void; + +} + +const SettingsDialog = ({ + open, + onClose, + onUpdateEmail, +}: Props) => { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + return ( + + + + Account Settings + + +
+ + + + setShowDeleteConfirm(false)} + /> +
+ + + + +
+
+ ); +}; + +export default SettingsDialog; diff --git a/frontend/src/shared/components/UserDashboard/UserBadges.tsx b/frontend/src/shared/components/UserDashboard/UserBadges.tsx new file mode 100644 index 00000000..9a920266 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserBadges.tsx @@ -0,0 +1,56 @@ +import { useUserBadges } from "@/api/queries/UserBadges"; +import type { Badge } from "@/shared/types/types"; +import bronzeBadge from "@/assets/bronzeBadge.svg"; +import silverBadge from "@/assets/silverBadge.svg"; +import goldBadge from "@/assets/goldBadge.svg"; +import customBadge from "@/assets/customBadge.svg"; + +const badgeColorMap: Record = { + BEGINNER: bronzeBadge, + INTERMEDIATE: silverBadge, + ADVANCED: goldBadge, + CUSTOM: customBadge +}; + +const UserBadges = () => { + const { data } = useUserBadges(); + const badges = data?.data ?? []; + + const grouped = badges.reduce>((acc, badge) => { + const type = badge.badgeType.toUpperCase(); + if (!acc[type]) acc[type] = []; + acc[type].push(badge); + return acc; + }, {}); + + return ( +
+

+ BADGES +

+
+ {Object.entries(grouped).map(([type, badgeList]) => { + const badge = badgeColorMap[type] ?? ""; + return ( +
+ Badge + {badgeList.length > 1 && ( + + ×{badgeList.length} + + )} +
+ {type} +
+
+ ); + })} +
+
+ ); +}; + +export default UserBadges; diff --git a/frontend/src/shared/components/UserDashboard/UserEmail.tsx b/frontend/src/shared/components/UserDashboard/UserEmail.tsx new file mode 100644 index 00000000..c291dc78 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserEmail.tsx @@ -0,0 +1,72 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/shared/components/ui/dialog"; +import { Input } from "@/shared/components/ui/input"; +import { Button } from "@/shared/components/ui/button"; +import { useState } from "react"; +import { useUpdateUserEmail } from "@/api/queries/UserProfileDetails"; +import { queryClient } from "@/api/react-query"; +import { LOGGED_IN_USER_QUERY_KEY } from "@/shared/constants/query-keys"; +import { toast } from "sonner"; + +interface Props { + defaultEmail: string; + onClose: () => void; +} + +const UserEmail = ({ defaultEmail, onClose }: Props) => { + const [email, setEmail] = useState(defaultEmail); + const { mutate: updateEmail, isPending } = useUpdateUserEmail(); + + const isValidEmail = (email: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const handleUpdate = () => { + if (!isValidEmail(email)) return; + updateEmail(email, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [LOGGED_IN_USER_QUERY_KEY] + }); + toast.success("email updated successfully"); + onClose(); + } + }); + }; + + return ( + + + + Update Email + + + We need your email, to send you notifications + + setEmail(e.target.value)} + placeholder="Enter new email" + className="selection:bg-cc-app-blue" + /> + + + + + + + ); +}; + +export default UserEmail; diff --git a/frontend/src/shared/components/UserDashboard/UserGoals.tsx b/frontend/src/shared/components/UserDashboard/UserGoals.tsx new file mode 100644 index 00000000..46005dd2 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserGoals.tsx @@ -0,0 +1,499 @@ +import { useState } from "react"; +import { Progress } from "@/shared/components/ui/progress"; +import { Button } from "@/shared/components/ui/button"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter +} from "@/shared/components/ui/dialog"; +import { Loader2 } from "lucide-react"; +import { + useAllContributionTypes, + useGoalLevels, + useGoalLevelTargets, + useResetUserGoalStatus, + useSetUserGoalLevel, + useUserCurrentGoalStatus +} from "@/api/queries/UserGoals"; +import { useQueryClient } from "@tanstack/react-query"; +import { USER_ACTIVE_GOAL_LEVEL_QUERY_KEY } from "@/shared/constants/query-keys"; +import type { + ContributionTypeDetail, + CustomGoalLevelTarget +} from "@/shared/types/types"; +import { toast } from "sonner"; +import information from "@/assets/information.png"; + +const UserGoals = () => { + const [dialogOpen, setDialogOpen] = useState(false); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const [isSettingLevel, setIsSettingLevel] = useState(false); + const [isCustomDialogOpen, setIsCustomDialogOpen] = useState(false); + const [levelTargetDialogOpen, setLevelTargetDialogOpen] = useState(false); + + const [customGoals, setCustomGoals] = useState([]); + const [selectedType, setSelectedType] = useState(""); + const [target, setTarget] = useState(""); + const [selectedLevel, setSelectedLevel] = useState(""); + + const { data: userGoalLevelRes, isLoading: isGoalLevelLoading } = + useUserCurrentGoalStatus(); + const { data: goalLevelsRes, isLoading: isGoalLevelsLoading } = + useGoalLevels(); + const { mutate: goalLevel, data: goalLevelTargetData } = + useGoalLevelTargets(); + const { mutate: setGoalLevel } = useSetUserGoalLevel(); + const { mutate: resetGoalStatus } = useResetUserGoalStatus(); + const { data: contributionTypesRes } = useAllContributionTypes(); + + const queryClient = useQueryClient(); + + const userLevel = userGoalLevelRes?.data ?? null; + const goalLevels = goalLevelsRes?.data ?? []; + const goalLevelTargets = goalLevelTargetData?.data ?? []; + const allTypes: ContributionTypeDetail[] = contributionTypesRes?.data ?? []; + + const createdAt = userLevel?.createdAt + ? new Date(userLevel?.createdAt) + : null; + + const isWithin48HoursOrGreaterThan30Days = (createdAt?: Date | null) => { + if (!createdAt) return false; + const diff = Date.now() - createdAt.getTime(); + return diff < 48 * 60 * 60 * 1000 || diff > 30 * 24 * 60 * 60 * 1000; + }; + + const handleLevelSelect = (level: string) => { + setIsSettingLevel(true); + + if (level.toLowerCase() === "custom") { + setDialogOpen(false); + setIsCustomDialogOpen(true); + setIsSettingLevel(false); + return; + } + + setGoalLevel( + { level, customTargets: [] }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [USER_ACTIVE_GOAL_LEVEL_QUERY_KEY] + }); + toast.success("goal set successfully"); + setIsSettingLevel(false); + setDialogOpen(false); + }, + onError: () => { + toast.error("Failed to set user goal level"); + setIsSettingLevel(false); + } + } + ); + }; + + const handleGoalReset = () => { + resetGoalStatus(undefined, { + onSuccess: () => { + queryClient.removeQueries({ + queryKey: [USER_ACTIVE_GOAL_LEVEL_QUERY_KEY] + }); + queryClient.refetchQueries({ + queryKey: [USER_ACTIVE_GOAL_LEVEL_QUERY_KEY] + }); + toast.success("goal reset successfully"); + setResetDialogOpen(false); + }, + onError: (err: any) => { + const message = + err?.response?.data?.message || "Failed to reset goal status"; + toast.error(message); + } + }); + }; + + const handleAddCustomGoal = () => { + if (!selectedType || !target) return; + if (customGoals.some(g => g.contributionType === selectedType)) return; + setCustomGoals(prev => [ + ...prev, + { contributionType: selectedType, target: Number(target) } + ]); + setSelectedType(""); + setTarget(""); + }; + + const handleSubmitCustomGoals = () => { + setGoalLevel( + { level: "Custom", customTargets: customGoals }, + { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [USER_ACTIVE_GOAL_LEVEL_QUERY_KEY] + }); + toast.success("goal set successfully"); + setIsCustomDialogOpen(false); + setCustomGoals([]); + }, + onError: () => toast.error("Failed to set custom goal targets") + } + ); + }; + + const handleViewLevelTarget = (selectedLevel: { + id: number; + level: string; + createdAt: string; + updatedAt: string; + }) => { + goalLevel(selectedLevel); + setSelectedLevel(selectedLevel.level); + setLevelTargetDialogOpen(true); + }; + + if (isGoalLevelLoading || isGoalLevelsLoading) { + return ( +
+ + Loading goals... +
+ ); + } + + return ( +
+
+
+

+ MY GOALS {userLevel?.level && `(${userLevel.level.toUpperCase()})`} +

+ + + Info + + + + + Goal Info + + +

+ You can set goals for a month. Once the month completes, your + goal will be reset automatically. +

+
+
+
+ + {isWithin48HoursOrGreaterThan30Days(createdAt) && ( + setResetDialogOpen(open)} + > + + + + + + Confirm Goal Reset + +

+ - Reset is available within 48 hours of setting a goal. +
- After 48 hours, goals reset automatically after the + month completes. +

+ + + + +
+
+ )} +
+ + {userLevel ? ( +
+ {userLevel.goalTargetProgress?.map((goal, idx) => { + const percent = goal.target + ? Math.min((goal.progress / goal.target) * 100, 100) + : 0; + return ( +
+
+ + {goal.contributionType.replace(/([A-Z])/g, " $1")} + + + {goal.progress}/{goal.target} + +
+ +
+ ); + })} +
+ ) : ( +
+

No Active Goal Set

+

+ You haven't selected a goal level yet. Choose a level to start + tracking contributions. +

+ + + + + + + + Select Goal Level + + + + {!isSettingLevel ? ( +
+
+ {goalLevels + .filter(level => level.level !== "Custom") + .map(level => ( +
+ + + +
+ ))} +
+ +
+ ) : ( +
+ + Setting your goal... +
+ )} + + + + +
+
+
+ )} + + {/* Level Target Dialog */} + + + + + Target for Level: {selectedLevel} + + + +
+

+ + Contribution Type + + + Target + +

+ + {goalLevelTargets.map(goal => ( +
+ + {goal.contributionType} + + + {goal.target} + +
+ ))} +
+ + + + +
+
+ + {/* Custom Goal Dialog */} + { + setIsCustomDialogOpen(open); + if (!open) { + setCustomGoals([]); + setSelectedType(""); + setTarget(""); + } + }} + > + + + Set Custom Contribution Goals + + +
+
+ + { + const val = e.target.value; + if (/^[1-9][0-9]*$/.test(val) || val === "") { + setTarget(val); + } + }} + /> + + +
+ + {customGoals.length > 0 && ( +
+ {customGoals.map((goal, idx) => ( +
+ + {goal.contributionType} + + {goal.target} + +
+ ))} +
+ )} +
+ + + + + +
+
+
+ ); +}; + +export default UserGoals; diff --git a/frontend/src/shared/components/UserDashboard/UserProfileCard.tsx b/frontend/src/shared/components/UserDashboard/UserProfileCard.tsx new file mode 100644 index 00000000..42bed017 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserProfileCard.tsx @@ -0,0 +1,28 @@ +import { Card } from "@/shared/components/ui/card"; +import { Separator } from "@/shared/components/ui/separator"; +import UserProfileDetails from "@/shared/components/UserDashboard/UserProfileDetails"; +import UserBadges from "@/shared/components/UserDashboard/UserBadges"; +import UserGoals from "@/shared/components/UserDashboard/UserGoals"; + +const UserProfileCard = () => { + return ( + +
+
+
+ {Array.from({ length: 30 }).map((_, i) => ( +
+ ))} +
+
+ + + + + +
+ + ); +}; + +export default UserProfileCard; diff --git a/frontend/src/shared/components/UserDashboard/UserProfileDetails.tsx b/frontend/src/shared/components/UserDashboard/UserProfileDetails.tsx new file mode 100644 index 00000000..012bc530 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserProfileDetails.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { ExternalLink, MoreVertical } from "lucide-react"; + +import Coin from "@/shared/components/common/Coin"; +import { Button } from "@/shared/components/ui/button"; +import DefaultProfilePic from "@/assets/default-profile-pic.svg"; +import { Separator } from "@/shared/components/ui/separator"; +import { useLoggedInUser } from "@/api/queries/UserProfileDetails"; +import { Link, useNavigate } from "react-router-dom"; + +import UserProfileMenu from "./UserProfileMenu"; +import UserEmail from "./UserEmail"; +import SettingsDialog from "./SettingsDialog"; +import { + clearAccessToken, + clearUserCredentials, + setUserData +} from "@/shared/utils/local-storage"; +import { toast } from "sonner"; +import type { AxiosError } from "axios"; + +const UserProfileDetails = () => { + const navigate = useNavigate(); + const { data, error, isError } = useLoggedInUser(); + const user = data?.data; + + if (isError) { + const axiosError = error as AxiosError<{ message: string }>; + toast.error(axiosError.response?.data?.message); + clearUserCredentials(); + navigate("/login"); + } + + setUserData(user); + + const [showSettingsDialog, setShowSettingsDialog] = useState(false); + const [showEmailDialog, setShowEmailDialog] = useState(false); + + const handleSettingsClick = () => setShowSettingsDialog(true); + const handleLogoutClick = () => { + clearAccessToken(); + navigate("/login"); + }; + + const handleUpdateEmail = () => { + setShowSettingsDialog(false); + setShowEmailDialog(true); + }; + + return ( +
+
+
+
+
+ Profile +
+
+ +
+ +
+
+ +
+

+ {user?.githubUsername || "username"} +

+ + + +
+
+ + + +
+
+ + + {user?.currentBalance || "0"} + +
+
+ + +
+
+ + setShowSettingsDialog(false)} + onUpdateEmail={handleUpdateEmail} + /> + {showEmailDialog && ( + setShowEmailDialog(false)} + /> + )} +
+ ); +}; + +export default UserProfileDetails; diff --git a/frontend/src/shared/components/UserDashboard/UserProfileMenu.tsx b/frontend/src/shared/components/UserDashboard/UserProfileMenu.tsx new file mode 100644 index 00000000..b62108d5 --- /dev/null +++ b/frontend/src/shared/components/UserDashboard/UserProfileMenu.tsx @@ -0,0 +1,47 @@ +import { MoreVertical } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator +} from "@/shared/components/ui/dropdown-menu"; +import { Button } from "@/shared/components/ui/button"; + +interface Props { + onSettingsClick: () => void; + onLogoutClick: () => void; +} + +const UserProfileMenu = ({ onSettingsClick, onLogoutClick }: Props) => { + return ( + + + + + + + Settings + + + + Logout + + + + ); +}; + +export default UserProfileMenu; diff --git a/frontend/src/shared/components/common/ActivityCard.tsx b/frontend/src/shared/components/common/ActivityCard.tsx new file mode 100644 index 00000000..ad15b4e4 --- /dev/null +++ b/frontend/src/shared/components/common/ActivityCard.tsx @@ -0,0 +1,61 @@ +import type { FC } from "react"; +import Coin from "@/shared/components/common/Coin"; +import { format } from "date-fns"; + +interface ActivityCardProps { + contributionType: string; + repositoryName?: string; + contributedAt: string; + balanceChange: number; + showLine: boolean; + isRepositoryActivity?: boolean; +} + +const ActivityCard: FC = ({ + contributionType, + repositoryName, + contributedAt, + balanceChange, + showLine = true, + isRepositoryActivity +}) => { + return ( +
+ {showLine && ( +
+ )} + +
+ +
+
+
+ {contributionType.replace(/([A-Z])/g, " $1")} +
+ {isRepositoryActivity ? null : ( +
+ Contributed to <{repositoryName}> +
+ )} + +
+ Contributed on {format(new Date(contributedAt), "MMM d yyyy")} +
+
+ + {balanceChange && ( +
+ + {balanceChange < 0 ? `-${Math.abs(balanceChange)}` : balanceChange} +
+ )} +
+
+ ); +}; + +export default ActivityCard; diff --git a/frontend/src/shared/components/common/BlockedAccountPage.tsx b/frontend/src/shared/components/common/BlockedAccountPage.tsx new file mode 100644 index 00000000..6ee86350 --- /dev/null +++ b/frontend/src/shared/components/common/BlockedAccountPage.tsx @@ -0,0 +1,52 @@ +import { + Card, + CardContent, + CardHeader, + CardFooter +} from "@/shared/components/ui/card"; +import { Button } from "@/shared/components/ui/button"; +import { clearAccessToken } from "@/shared/utils/local-storage"; +import { useNavigate } from "react-router-dom"; +import { AlertTriangle } from "lucide-react"; + +const BlockedAccountPage = () => { + const navigate = useNavigate(); + clearAccessToken(); + + const handleBackToLogin = () => { + clearAccessToken(); + navigate("/login"); + }; + + return ( +
+ + + +

+ Account Blocked +

+
+ + +

+ Your account has been blocked due to policy violations or unusual + activity. If you believe this is a mistake, please contact our + support team. +

+
+ + + + +
+
+ ); +}; + +export default BlockedAccountPage; diff --git a/frontend/src/shared/components/common/Coin.tsx b/frontend/src/shared/components/common/Coin.tsx new file mode 100644 index 00000000..0ba372f8 --- /dev/null +++ b/frontend/src/shared/components/common/Coin.tsx @@ -0,0 +1,9 @@ +import coinSvg from "@/assets/Coin.svg"; + +const Coin = () => { + return ( + Coin + ); +}; + +export default Coin; diff --git a/frontend/src/shared/components/common/CoinsInfo.tsx b/frontend/src/shared/components/common/CoinsInfo.tsx new file mode 100644 index 00000000..5dd3e573 --- /dev/null +++ b/frontend/src/shared/components/common/CoinsInfo.tsx @@ -0,0 +1,72 @@ +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/components/ui/dialog"; +import { Button } from "@/shared/components/ui/button"; +import { useAllContributionTypes } from "@/api/queries/UserGoals"; + +const CoinsInfo = () => { + const { data, isLoading } = useAllContributionTypes(); + const contributions = data?.data ?? []; + + return ( + + + + + + + + How does the point system work? + + + +
+ We assign coins for every open-source contribution you make on GitHub. + Contributions are updated everyday at midnight, so you can see + contributions you made before 12 am yesterday +
+ +
+ Current Point Structure: +
+ +
+ {isLoading ? ( +
Loading...
+ ) : contributions.length === 0 ? ( +
+ No data available. +
+ ) : ( +
    + {contributions.map(c => ( +
  • + {c.contributionType} + + {c.score} pts + +
  • + ))} +
+ )} +
+ + +
+
+ ); +}; + +export default CoinsInfo; diff --git a/frontend/src/shared/components/ui/avatar.tsx b/frontend/src/shared/components/ui/avatar.tsx new file mode 100644 index 00000000..834f26fd --- /dev/null +++ b/frontend/src/shared/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/shared/utils/tailwindcss" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/src/shared/components/ui/button.tsx b/frontend/src/shared/components/ui/button.tsx new file mode 100644 index 00000000..5fc0eaeb --- /dev/null +++ b/frontend/src/shared/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { type VariantProps, cva } from "class-variance-authority"; +import { Slot } from "@radix-ui/react-slot"; + +import { cn } from "@/shared/utils/tailwindcss"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + primary: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 hover:cursor-pointer", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 hover:cursor-pointer", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 hover:cursor-pointer", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 hover:cursor-pointer", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 hover:cursor-pointer", + link: "text-primary underline-offset-4 hover:underline hover:cursor-pointer", + ccAppOutlineMidBlue: + "bg-cc-app-mid-blue hover:bg-cc-app-blue rounded-sm border border-white text-white hover:cursor-pointer hover:cursor-pointer", + ccAppOutline: + "border-cc-app-mid-blue text-cc-app-blue hover:bg-cc-app-mid-blue/5 rounded-sm border focus:outline-none hover:cursor-pointer", + ccAppOutlineRed: + "border-red text-white hover:bg-red-800 rounded-sm border focus:outline-none bg-red-700 hover:cursor-pointer", + success: + "bg-green-600 text-white shadow-xs hover:bg-green-700 focus-visible:ring-green-500/20 dark:focus-visible:ring-green-400/40 hover:cursor-pointer" + }, + size: { + md: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9" + } + }, + defaultVariants: { + variant: "primary", + size: "md" + } + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/frontend/src/shared/components/ui/card.tsx b/frontend/src/shared/components/ui/card.tsx new file mode 100644 index 00000000..f27e1678 --- /dev/null +++ b/frontend/src/shared/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/shared/utils/tailwindcss"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +}; diff --git a/frontend/src/shared/components/ui/dialog.tsx b/frontend/src/shared/components/ui/dialog.tsx new file mode 100644 index 00000000..72339f36 --- /dev/null +++ b/frontend/src/shared/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/shared/utils/tailwindcss" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/shared/components/ui/dropdown-menu.tsx b/frontend/src/shared/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..05a5157c --- /dev/null +++ b/frontend/src/shared/components/ui/dropdown-menu.tsx @@ -0,0 +1,255 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/shared/utils/tailwindcss" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/shared/components/ui/input.tsx b/frontend/src/shared/components/ui/input.tsx new file mode 100644 index 00000000..94d69579 --- /dev/null +++ b/frontend/src/shared/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/shared/utils/tailwindcss" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/shared/components/ui/progress.tsx b/frontend/src/shared/components/ui/progress.tsx new file mode 100644 index 00000000..d2110be8 --- /dev/null +++ b/frontend/src/shared/components/ui/progress.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +import { cn } from "@/shared/utils/tailwindcss"; + +function Progress({ + className, + indicatorClassName, + value, + ...props +}: React.ComponentProps & { + indicatorClassName?: string; +}) { + return ( + + + + ); +} + +export { Progress }; diff --git a/frontend/src/shared/components/ui/scroll-area.tsx b/frontend/src/shared/components/ui/scroll-area.tsx new file mode 100644 index 00000000..446e9a79 --- /dev/null +++ b/frontend/src/shared/components/ui/scroll-area.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/shared/utils/tailwindcss" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/frontend/src/shared/components/ui/select.tsx b/frontend/src/shared/components/ui/select.tsx new file mode 100644 index 00000000..e45ae7b1 --- /dev/null +++ b/frontend/src/shared/components/ui/select.tsx @@ -0,0 +1,183 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/shared/utils/tailwindcss" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/frontend/src/shared/components/ui/separator.tsx b/frontend/src/shared/components/ui/separator.tsx new file mode 100644 index 00000000..70af3ab9 --- /dev/null +++ b/frontend/src/shared/components/ui/separator.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/shared/utils/tailwindcss"; + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Separator }; diff --git a/frontend/src/shared/components/ui/sonner.tsx b/frontend/src/shared/components/ui/sonner.tsx new file mode 100644 index 00000000..85514eca --- /dev/null +++ b/frontend/src/shared/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from "next-themes"; +import { Toaster as Sonner, type ToasterProps } from "sonner"; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/frontend/src/shared/components/ui/table.tsx b/frontend/src/shared/components/ui/table.tsx new file mode 100644 index 00000000..99ee356e --- /dev/null +++ b/frontend/src/shared/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/shared/utils/tailwindcss" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/frontend/src/shared/constants/constants.ts b/frontend/src/shared/constants/constants.ts new file mode 100644 index 00000000..ecf83ebf --- /dev/null +++ b/frontend/src/shared/constants/constants.ts @@ -0,0 +1,27 @@ +export const AuthLayoutDetails = [ + "Fuel your open-source journey — stay consistent, stay rewarded.", + "Every commit, issue & comment earns real rewards (Virtual money).", + "Beginner-friendly — even small contributions count.", + "Set monthly goals and track your GitHub activity automatically.", + "Join a community driven by passion, not ads or data sales.", + "Supported by developers. Funded by developers. Built for developers." +]; + +export const LangColor: Record = { + JavaScript: "bg-yellow-400", + TypeScript: "bg-blue-500", + Python: "bg-green-500", + Java: "bg-red-500", + Go: "bg-cyan-500", + Rust: "bg-orange-700", + C: "bg-gray-500", + "C++": "bg-purple-600", + Ruby: "bg-pink-500", + PHP: "bg-indigo-500", + Swift: "bg-orange-400", + Kotlin: "bg-violet-500", + Dart: "bg-sky-500", + HTML: "bg-orange-300", + CSS: "bg-blue-300", + Shell: "bg-zinc-600" +}; diff --git a/frontend/src/shared/constants/endpoints.ts b/frontend/src/shared/constants/endpoints.ts new file mode 100644 index 00000000..ac10321c --- /dev/null +++ b/frontend/src/shared/constants/endpoints.ts @@ -0,0 +1,3 @@ +export const GITHUB_AUTH_URL = import.meta.env.VITE_GITHUB_AUTH_URL as string; +export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL as string; +export const FRONTEND_URL = import.meta.env.VITE_FRONTEND_URL as string; diff --git a/frontend/src/shared/constants/layout.ts b/frontend/src/shared/constants/layout.ts new file mode 100644 index 00000000..7b59a8d1 --- /dev/null +++ b/frontend/src/shared/constants/layout.ts @@ -0,0 +1,8 @@ +export const Layout = { + AuthLayout: "AuthLayout", + DashboardLayout: "DashboardLayout", + AdminLayout: "AdminLayout", + None: "None" +} as const; + +export type LayoutType = (typeof Layout)[keyof typeof Layout]; diff --git a/frontend/src/shared/constants/local-storage.ts b/frontend/src/shared/constants/local-storage.ts new file mode 100644 index 00000000..7d1daf7c --- /dev/null +++ b/frontend/src/shared/constants/local-storage.ts @@ -0,0 +1,2 @@ +export const ACCESS_TOKEN_KEY = "cc-7db23e66-accessToken"; +export const USER_DATA_KEY="userData" diff --git a/frontend/src/shared/constants/query-keys.ts b/frontend/src/shared/constants/query-keys.ts new file mode 100644 index 00000000..52a99424 --- /dev/null +++ b/frontend/src/shared/constants/query-keys.ts @@ -0,0 +1,21 @@ +export const LOGGED_IN_USER_QUERY_KEY = "logged-in-user"; +export const USER_BADGES_QUERY_KEY = "user-badges"; +export const LEADERBOARD_QUERY_KEY = "leaderboard"; +export const CURRENT_USER_RANK_QUERY_KEY = "current-user-rank"; +export const RECENT_ACTIVITIES_QUERY_KEY = "recent-activities"; +export const OVERVIEW_QUERY_KEY = "overview"; +export const REPOSITORIES_KEY = "repositories"; +export const REPOSITORY_KEY = "repository"; +export const REPOSITORY_CONTRIBUTORS_QUERY_KEY = "repository-contributors"; +export const REPOSITORY_LANGUAGES_QUERY_KEY = "repository-languages"; +export const REPOSITORY_ACTIVITIES_QUERY_KEY = "repository-activites"; +export const USER_ACTIVE_GOAL_LEVEL_QUERY_KEY = "user-goal-level"; +export const GOAL_LEVELS_QUERY_KEY = "goal-levels"; +export const GOAL_LEVEL_TARGETS_QUERY_KEY = "goal-level-targets"; +export const USER_GOAL_LEVEL_PROGRESS_QUERY_KEY = "goal-level-progresss"; +export const CONTRIBUTION_TYPES_QUERY_KEY = "contribution-types"; +export const GITHUB_OAUTH_LOGIN_QUERY_KEY = "github-oauth-login"; +export const USER_GOAL_LEVEL_UPDATE_QUERY_KEY = "user-goal-level-update"; +export const USER_GOAL_LEVEL_SUMMARY_QUERY_KEY="user-goal-level-summary" + +export const GET_ALL_USERS_QUERY_KEY = "get-all-users"; \ No newline at end of file diff --git a/frontend/src/shared/constants/routes.ts b/frontend/src/shared/constants/routes.ts new file mode 100644 index 00000000..6864ca58 --- /dev/null +++ b/frontend/src/shared/constants/routes.ts @@ -0,0 +1,10 @@ +export const LOGIN_PATH = "/login"; +export const USER_DASHBOARD_PATH = "/"; +export const MY_CONTRIBUTIONS_PATH = "/my-contributions"; +export const REPOSITORY_DETAILS_PATH = "/repositories/:repoid"; +export const ACCOUNT_INFO_PATH = "/account/info"; + +export const ADMIN_LOGIN_PATH = "/admin/login"; +export const ADMIN_USERS_PATH = "/admin/users"; +export const ADMIN_SCORE_CONFIGURE_PATH = "/admin/configure/score"; +export const ADMIN_LEADERBOARD_PATH="/admin/leaderboard" diff --git a/frontend/src/shared/layout/AdminLayout.tsx b/frontend/src/shared/layout/AdminLayout.tsx new file mode 100644 index 00000000..ba043533 --- /dev/null +++ b/frontend/src/shared/layout/AdminLayout.tsx @@ -0,0 +1,162 @@ +import React, { type FC, type ReactNode } from "react"; +import { User, BarChart3, Users, ChevronRight, LogOut, Trophy } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { ADMIN_LOGIN_PATH } from "../constants/routes"; +import { Button } from "../components/ui/button"; +import { clearAccessToken } from "../utils/local-storage"; + +interface AdminLayoutProps { + children: ReactNode; +} + +const AdminLayout: FC = ({ children }) => { + const [activeItem, setActiveItem] = React.useState("Users"); + const navigate = useNavigate(); + + const menuItems = [ + { + name: "Configure Score", + icon: BarChart3, + path: "/admin/configure/score" + }, + { name: "Users", icon: Users, path: "/admin/users" }, + { name: "View Leaderboard", icon: Trophy, path: "/admin/leaderboard" } + ]; + + const handleMenuClick = (itemName: string, path: string) => { + setActiveItem(itemName); + navigate(path); + }; + + return ( +
+
+
+
+
+
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+ </> +
+
+ + CODE CURIOSITY + + + Admin Portal + +
+
+
+ + +
+ +
+ +
+
+ +
+
+
+
+
+

+ {activeItem} +

+

+ Manage your {activeItem.toLowerCase()} settings and + configurations +

+
+
+ +
+
+
+ +
+
+ + Admin + + Administrator +
+
+
+
+
+ +
+
{children}
+
+
+
+ ); +}; + +export default AdminLayout; diff --git a/frontend/src/shared/layout/AppLayout.tsx b/frontend/src/shared/layout/AppLayout.tsx new file mode 100644 index 00000000..b41c295f --- /dev/null +++ b/frontend/src/shared/layout/AppLayout.tsx @@ -0,0 +1,17 @@ +import { type ReactNode } from "react"; + +interface AppLayoutProps { + children: ReactNode; +} + +const AppLayout = ({ children }: AppLayoutProps) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default AppLayout; diff --git a/frontend/src/shared/layout/AuthLayout.tsx b/frontend/src/shared/layout/AuthLayout.tsx new file mode 100644 index 00000000..8f7d7750 --- /dev/null +++ b/frontend/src/shared/layout/AuthLayout.tsx @@ -0,0 +1,96 @@ +import { type FC, type ReactNode, useEffect } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { CheckCircle } from "lucide-react"; + +import { Card } from "@/shared/components/ui/card"; +import { LOGIN_PATH, USER_DASHBOARD_PATH } from "@/shared/constants/routes"; +import { getAccessToken } from "@/shared/utils/local-storage"; +import { AuthLayoutDetails } from "../constants/constants"; + +interface AuthLayoutProps { + children: ReactNode; +} + +const AuthLayout: FC = ({ children }) => { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const userAccessToken = getAccessToken(); + const shouldRedirect = [LOGIN_PATH].includes(location.pathname); + + if (userAccessToken && shouldRedirect) { + navigate(USER_DASHBOARD_PATH); + } + }, [navigate, location]); + + return ( +
+
+ +
+
+ {Array.from({ length: 64 }).map((_, i) => ( +
+ ))} +
+
+ +
+
+
+
+

+ Code Curiosity +

+
+ +
+ {AuthLayoutDetails.map((text, i) => ( +
+ + + {text} + +
+ ))} +
+
+
+
+ +
+
+ {Array.from({ length: 64 }).map((_, i) => ( +
+ ))} +
+
+ + + {children} + {location.pathname.startsWith("/admin") ? ( + + Log in as user? + + ) : ( + + {/* Log in as admin? */} + + )} + +
+
+ ); +}; + +export default AuthLayout; diff --git a/frontend/src/shared/layout/UserDashboardLayout.tsx b/frontend/src/shared/layout/UserDashboardLayout.tsx new file mode 100644 index 00000000..fd087c22 --- /dev/null +++ b/frontend/src/shared/layout/UserDashboardLayout.tsx @@ -0,0 +1,24 @@ +import type { FC, ReactNode } from "react"; + +import Navbar from "@/shared/components/UserDashboard/Navbar"; +import UserProfileCard from "@/shared/components/UserDashboard/UserProfileCard"; + +interface UserDashboardLayoutProps { + children?: ReactNode; +} + +const UserDashboardLayout: FC = ({ children }) => { + return ( +
+ +
+ +
+ {children} +
+
+
+ ); +}; + +export default UserDashboardLayout; diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts new file mode 100644 index 00000000..c4e703da --- /dev/null +++ b/frontend/src/shared/types/api.ts @@ -0,0 +1,4 @@ +export interface ApiResponse { + message :string; + data: T; +} \ No newline at end of file diff --git a/frontend/src/shared/types/auth.ts b/frontend/src/shared/types/auth.ts new file mode 100644 index 00000000..06c2c744 --- /dev/null +++ b/frontend/src/shared/types/auth.ts @@ -0,0 +1,16 @@ +export interface User { + id: number; + githubId: string; + githubUsername: string; + avatarUrl: string; + email: string | null; + currentActiveGoalId: number | null; + currentBalance: number; + isBlocked: boolean; + isAdmin: boolean; + password: string; + isDeleted: boolean; + DeletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/frontend/src/shared/types/navbar.ts b/frontend/src/shared/types/navbar.ts new file mode 100644 index 00000000..752aba1c --- /dev/null +++ b/frontend/src/shared/types/navbar.ts @@ -0,0 +1,9 @@ +import { + MY_CONTRIBUTIONS_PATH, + USER_DASHBOARD_PATH +} from "@/shared/constants/routes"; + +export const USER_DASHBOARD_NAVBAR_OPTIONS = [ + { name: "Dashboard", path: USER_DASHBOARD_PATH }, + { name: "My Contributions", path: MY_CONTRIBUTIONS_PATH }, +]; diff --git a/frontend/src/shared/types/types.ts b/frontend/src/shared/types/types.ts new file mode 100644 index 00000000..2929b54d --- /dev/null +++ b/frontend/src/shared/types/types.ts @@ -0,0 +1,238 @@ +export interface User { + userId: number; + githubId: number; + githubUsername: string; + email: string; + avatarUrl: string; + currentBalance: number; + isBlocked: boolean; + isAdmin: boolean; + password: string; + isDeleted: boolean; + deletedAt: string; + createdAt: string; + updatedAt: string; +} + +export interface Badge { + id: number; + userId: number; + badgeType: string; + earnedAt: string; + createdAt: string; +} + +export interface LeaderboardUser { + id: number; + githubUsername: string; + avatarUrl: string; + contributedReposCount: number; + currentBalance: number; + rank: number; +} + +export interface RecentActivity { + userId: number; + repositoryId: number; + contributionScoreId: number; + contributionType: string; + balanceChange: number; + contributedAt: string; + githubEventId: number; + githubRepoId: number; + repoName: string; + description: string; + languagesUrl: string; + repoUrl: string; + ownerName: string; + updateDate: string; + contributorsUrl: string; +} + +export interface Overview { + type: string; + count: number; + totalCoins: number; + month: string; +} + +export interface Repositories { + id: number; + githubRepoId: number; + repoName: string; + description: string; + languagesUrl: string; + repoUrl: string; + ownerName: string; + updateDate: string; + contributorsUrl: string; + createdAt: string; + updatedAt: string; + languages: string[]; + totalCoinsEarned: number; +} + +export interface Language { + name: string; + bytes: number; + percentage: number; +} + +export interface Repository { + id: number; + githubRepoId: number; + repoName: string; + description: string; + languagesUrl: string; + repoUrl: string; + ownerName: string; + updateDate: string; + contributorsUrl: string; + createdAt: string; + updatedAt: string; + languages: string[]; +} + +export interface Contributor { + id: number; + name: string; + avatar_url: string; + github_url: string; + contributions: number; +} + +export interface RepositoryActivity { + id: number; + userId: number; + repositoryId: number; + contributionScoreId: number; + contributionType: string; + balanceChange: number; + contributedAt: string; + githubEventId: string; + createdAt: string; + updatedAt: string; +} + +export interface GoalLevel { + id: number; + level: string; + createdAt: string; + updatedAt: string; +} + +export interface GoalLevelTarget { + id: number; + goalLevelId: number; + contributionType: string; + target: number; + createdAt: string; + updatedAt: string; +} + +export interface UserGoalTargetProgress { + contributionType: string; + target: number; + progress: number; +} + +export interface UserCurrentGoalStatus { + userGoalId: number; + level: string; + status: string; + monthStartedAt: string; + createdAt: string; + updatedAt: string; + goalTargetProgress: UserGoalTargetProgress[]; +} + +export interface UserGoal { + id: number; + userId: number; + goalId: number; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface SetUserGoalLevelRequest { + level: string; + customTargets: CustomGoalLevelTarget[]; +} + +export interface CustomGoalLevelTarget { + contributionType: string; + target: number; +} + +export interface ContributionTypeDetail { + id: number; + adminId: number; + contributionType: string; + score: number; + createdAt: string; + updatedAt: string; +} + +export interface UserGoalLevelStatus { + id: number; + userId: number; + goalId: number; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface Admin { + userId: number; + githubId: number; + githubUsername: string; + email: string; + avatarUrl: string; + currentBalance: number; + currentActiveGoalId: { + Int64: number; + Valid: boolean; + }; + isBlocked: boolean; + isAdmin: boolean; + password: string; + isDeleted: boolean; + deletedAt: { + Time: string; + Valid: boolean; + }; + createdAt: string; + updatedAt: string; + jwtToken: string; +} + +export interface AdminCredentials { + email: string; + password: string; +} + +export interface ContributionScore { + id: number; + adminId: number; + contributionType: string; + score: number; + createdAt: string; + updatedAt: string; +} + +export interface ContributionScoreUpdate { + contributionType: string; + score: number; +} + +export interface GoalSummary { + id: number; + userId: number; + snapshotDate: string; + incompleteGoalsCount: number; + targetSet: number; + targetCompleted: number; + createdAt: string; + updatedAt: string; +} diff --git a/frontend/src/shared/utils/local-storage.ts b/frontend/src/shared/utils/local-storage.ts new file mode 100644 index 00000000..41b0c8a5 --- /dev/null +++ b/frontend/src/shared/utils/local-storage.ts @@ -0,0 +1,33 @@ +import { ACCESS_TOKEN_KEY, USER_DATA_KEY } from "@/shared/constants/local-storage"; +import type { User } from "../types/types"; + +export const getAccessToken = (): string | null => { + return localStorage.getItem(ACCESS_TOKEN_KEY) || null; +}; + +export const setAccessToken = (token: string) => { + localStorage.setItem(ACCESS_TOKEN_KEY, token); +}; + +export const clearAccessToken = () => { + localStorage.removeItem(ACCESS_TOKEN_KEY); +}; + +export const getUserData = () => { + try { + const userData = localStorage.getItem(USER_DATA_KEY); + return userData ? (JSON.parse(userData) as User) : null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return null; + } +}; + +export const setUserData = (data: any) => { + localStorage.setItem(USER_DATA_KEY, JSON.stringify(data)); +}; + +export const clearUserCredentials = () => { + localStorage.removeItem(USER_DATA_KEY); + localStorage.removeItem(ACCESS_TOKEN_KEY); +}; \ No newline at end of file diff --git a/frontend/src/shared/utils/tailwindcss.ts b/frontend/src/shared/utils/tailwindcss.ts new file mode 100644 index 00000000..365058ce --- /dev/null +++ b/frontend/src/shared/utils/tailwindcss.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..776744a6 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BACKEND_URL: string; + // add other env vars here +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 00000000..f7c4e4d7 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,64 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)" + }, + colors: { + cclightblue: "hsl(var(--cc-app-light-blue))", + ccskyblue: "hsl(var(--cc-app-sky-blue))", + ccmidblue: "hsl(var(--cc-app-mid-blue))", + ccappblue: "hsl(var(--cc-app-blue))", + ccappgraybackground: "hsl(var(--cc-app-gray-background))", + ccapporange: "hsl(var(--cc-app-orange))", + ccappyellow: "hsl(var(--cc-app-yellow))", + + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))" + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))" + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))" + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))" + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))" + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))" + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))" + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + chart: { + 1: "hsl(var(--chart-1))", + 2: "hsl(var(--chart-2))", + 3: "hsl(var(--chart-3))", + 4: "hsl(var(--chart-4))", + 5: "hsl(var(--chart-5))" + } + } + } + }, + plugins: [] +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 00000000..f39faca8 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..1e173931 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..f85a3990 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..f916da50 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src") + } + } +}); diff --git a/go.mod b/go.mod deleted file mode 100644 index e0eeadaa..00000000 --- a/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/joshsoftware/code-curiosity-2025 - -go 1.23.4 - -require ( - github.com/golang-jwt/jwt/v4 v4.5.2 - github.com/ilyakaznacheev/cleanenv v1.5.0 - github.com/jmoiron/sqlx v1.4.0 - github.com/lib/pq v1.10.9 - golang.org/x/oauth2 v0.29.0 -) - -require ( - github.com/BurntSushi/toml v1.2.1 // indirect - github.com/golang-migrate/migrate/v4 v4.18.3 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/kr/pretty v0.3.1 // indirect - go.uber.org/atomic v1.11.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index ddd7ccb7..00000000 --- a/go.sum +++ /dev/null @@ -1,52 +0,0 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= -github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= -github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= -github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= -github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= -github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= -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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= -olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/app/auth/domain.go b/internal/app/auth/domain.go deleted file mode 100644 index 00f61f9a..00000000 --- a/internal/app/auth/domain.go +++ /dev/null @@ -1,40 +0,0 @@ -package auth - -import ( - "database/sql" - "time" -) - -const ( - LoginWithGithubFailed = "LoginWithGithubFailed" - AccessTokenCookieName = "AccessToken" - GitHubOAuthState = "state" - GithubOauthScope = "read:user" - GetUserGithubUrl = "https://api.github.com/user" - GetUserEmailUrl = "https://api.github.com/user/emails" -) - -type User struct { - Id int `json:"user_id"` - GithubId int `json:"github_id"` - GithubUsername string `json:"github_username"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` - CurrentBalance int `json:"current_balance"` - CurrentActiveGoalId sql.NullInt64 `json:"current_active_goal_id"` - IsBlocked bool `json:"is_blocked"` - IsAdmin bool `json:"is_admin"` - Password string `json:"password"` - IsDeleted bool `json:"is_deleted"` - DeletedAt sql.NullTime `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type GithubUserResponse struct { - GithubId int `json:"id"` - GithubUsername string `json:"login"` - AvatarUrl string `json:"avatar_url"` - Email string `json:"email"` - IsAdmin bool `json:"is_admin"` -} diff --git a/internal/app/dependencies.go b/internal/app/dependencies.go deleted file mode 100644 index fa49370d..00000000 --- a/internal/app/dependencies.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "github.com/jmoiron/sqlx" - "github.com/joshsoftware/code-curiosity-2025/internal/app/auth" - "github.com/joshsoftware/code-curiosity-2025/internal/app/user" - "github.com/joshsoftware/code-curiosity-2025/internal/config" - "github.com/joshsoftware/code-curiosity-2025/internal/repository" -) - -type Dependencies struct { - AuthService auth.Service - UserService user.Service - AuthHandler auth.Handler - UserHandler user.Handler - AppCfg config.AppConfig -} - -func InitDependencies(db *sqlx.DB, appCfg config.AppConfig) Dependencies { - userRepository := repository.NewUserRepository(db) - - userService := user.NewService(userRepository) - authService := auth.NewService(userService, appCfg) - - authHandler := auth.NewHandler(authService, appCfg) - userHandler := user.NewHandler(userService) - - return Dependencies{ - AuthService: authService, - UserService: userService, - AuthHandler: authHandler, - UserHandler: userHandler, - AppCfg: appCfg, - } -} diff --git a/internal/app/router.go b/internal/app/router.go deleted file mode 100644 index 072a53a1..00000000 --- a/internal/app/router.go +++ /dev/null @@ -1,24 +0,0 @@ -package app - -import ( - "net/http" - - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" -) - -func NewRouter(deps Dependencies) http.Handler { - router := http.NewServeMux() - - router.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) { - response.WriteJson(w, http.StatusOK, "Server is up and running..", nil) - }) - - router.HandleFunc("GET /api/v1/auth/github", deps.AuthHandler.GithubOAuthLoginUrl) - router.HandleFunc("GET /api/v1/auth/github/callback", deps.AuthHandler.GithubOAuthLoginCallback) - router.HandleFunc("GET /api/v1/auth/user", middleware.Authentication(deps.AuthHandler.GetLoggedInUser, deps.AppCfg)) - - router.HandleFunc("PATCH /api/v1/user/email", middleware.Authentication(deps.UserHandler.UpdateUserEmail, deps.AppCfg)) - - return middleware.CorsMiddleware(router, deps.AppCfg) -} diff --git a/internal/app/user/domain.go b/internal/app/user/domain.go deleted file mode 100644 index e2d9e6c7..00000000 --- a/internal/app/user/domain.go +++ /dev/null @@ -1,35 +0,0 @@ -package user - -import ( - "database/sql" - "time" -) - -type User struct { - Id int `json:"user_id"` - GithubId int `json:"github_id"` - GithubUsername string `json:"github_username"` - Email string `json:"email"` - AvatarUrl string `json:"avatar_url"` - CurrentBalance int `json:"current_balance"` - CurrentActiveGoalId sql.NullInt64 `json:"current_active_goal_id"` - IsBlocked bool `json:"is_blocked"` - IsAdmin bool `json:"is_admin"` - Password string `json:"password"` - IsDeleted bool `json:"is_deleted"` - DeletedAt sql.NullTime `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CreateUserRequestBody struct { - GithubId int `json:"id"` - GithubUsername string `json:"github_id"` - AvatarUrl string `json:"avatar_url"` - Email string `json:"email"` - IsAdmin bool `json:"is_admin"` -} - -type Email struct { - Email string `json:"email"` -} diff --git a/internal/app/user/handler.go b/internal/app/user/handler.go deleted file mode 100644 index 00bcd51e..00000000 --- a/internal/app/user/handler.go +++ /dev/null @@ -1,46 +0,0 @@ -package user - -import ( - "encoding/json" - "log/slog" - "net/http" - - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/response" -) - -type handler struct { - userService Service -} - -type Handler interface { - UpdateUserEmail(w http.ResponseWriter, r *http.Request) -} - -func NewHandler(userService Service) Handler { - return &handler{ - userService: userService, - } -} - -func (h *handler) UpdateUserEmail(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - var requestBody Email - err := json.NewDecoder(r.Body).Decode(&requestBody) - if err != nil { - slog.Error(apperrors.ErrFailedMarshal.Error(), "error", err) - response.WriteJson(w, http.StatusBadRequest, apperrors.ErrInvalidRequestBody.Error(), nil) - return - } - - err = h.userService.UpdateUserEmail(ctx, requestBody.Email) - if err != nil { - slog.Error("failed to update user email", "error", err) - status, errorMessage := apperrors.MapError(err) - response.WriteJson(w, status, errorMessage, nil) - return - } - - response.WriteJson(w, http.StatusOK, "email updated successfully", nil) -} diff --git a/internal/app/user/service.go b/internal/app/user/service.go deleted file mode 100644 index 93b85726..00000000 --- a/internal/app/user/service.go +++ /dev/null @@ -1,76 +0,0 @@ -package user - -import ( - "context" - "log/slog" - - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/middleware" - "github.com/joshsoftware/code-curiosity-2025/internal/repository" -) - -type service struct { - userRepository repository.UserRepository -} - -type Service interface { - GetUserById(ctx context.Context, userId int) (User, error) - GetUserByGithubId(ctx context.Context, githubId int) (User, error) - CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) - UpdateUserEmail(ctx context.Context, email string) error -} - -func NewService(userRepository repository.UserRepository) Service { - return &service{ - userRepository: userRepository, - } -} - -func (s *service) GetUserById(ctx context.Context, userId int) (User, error) { - userInfo, err := s.userRepository.GetUserById(ctx, nil, userId) - if err != nil { - slog.Error("failed to get user by id", "error", err) - return User{}, err - } - - return User(userInfo), nil - -} - -func (s *service) GetUserByGithubId(ctx context.Context, githubId int) (User, error) { - userInfo, err := s.userRepository.GetUserByGithubId(ctx, nil, githubId) - if err != nil { - slog.Error("failed to get user by github id", "error", err) - return User{}, err - } - - return User(userInfo), nil -} - -func (s *service) CreateUser(ctx context.Context, userInfo CreateUserRequestBody) (User, error) { - user, err := s.userRepository.CreateUser(ctx, nil, repository.CreateUserRequestBody(userInfo)) - if err != nil { - slog.Error("failed to create user", "error", err) - return User{}, apperrors.ErrUserCreationFailed - } - - return User(user), nil -} - -func (s *service) UpdateUserEmail(ctx context.Context, email string) error { - userIdValue := ctx.Value(middleware.UserIdKey) - - userId, ok := userIdValue.(int) - if !ok { - slog.Error("error obtaining user id from context") - return apperrors.ErrInternalServer - } - - err := s.userRepository.UpdateUserEmail(ctx, nil, userId, email) - if err != nil { - slog.Error("failed to update user email", "error", err) - return err - } - - return nil -} diff --git a/internal/pkg/apperrors/errors.go b/internal/pkg/apperrors/errors.go deleted file mode 100644 index 5c7244dd..00000000 --- a/internal/pkg/apperrors/errors.go +++ /dev/null @@ -1,50 +0,0 @@ -package apperrors - -import ( - "errors" - "net/http" -) - -var ( - ErrInternalServer = errors.New("internal server error") - - ErrInvalidRequestBody = errors.New("invalid or missing parameters in the request body") - ErrInvalidQueryParams = errors.New("invalid or missing query parameters") - ErrFailedMarshal = errors.New("failed to parse request body") - - ErrUnauthorizedAccess = errors.New("unauthorized. please provide a valid access token") - ErrAccessForbidden = errors.New("access forbidden") - ErrInvalidToken = errors.New("invalid or expired token") - - ErrFailedInitializingLogger = errors.New("failed to initialize logger") - ErrNoAppConfigPath = errors.New("no config path provided") - ErrFailedToLoadAppConfig = errors.New("failed to load environment configuration") - - ErrLoginWithGithubFailed = errors.New("failed to login with Github") - ErrGithubTokenExchangeFailed = errors.New("failed to exchange Github token") - ErrFailedToGetGithubUser = errors.New("failed to get Github user info") - ErrFailedToGetUserEmail = errors.New("failed to get user email from Github") - - ErrUserNotFound = errors.New("user not found") - ErrUserCreationFailed = errors.New("failed to create user") - - ErrJWTCreationFailed = errors.New("failed to create jwt token") - ErrAuthorizationFailed=errors.New("failed to authorize user") -) - -func MapError(err error) (statusCode int, errMessage string) { - switch err { - case ErrInvalidRequestBody, ErrInvalidQueryParams: - return http.StatusBadRequest, err.Error() - case ErrUnauthorizedAccess: - return http.StatusUnauthorized, err.Error() - case ErrAccessForbidden: - return http.StatusForbidden, err.Error() - case ErrUserNotFound: - return http.StatusNotFound, err.Error() - case ErrInvalidToken: - return http.StatusUnprocessableEntity, err.Error() - default: - return http.StatusInternalServerError, ErrInternalServer.Error() - } -} diff --git a/internal/repository/domain.go b/internal/repository/domain.go deleted file mode 100644 index 8bb35ae4..00000000 --- a/internal/repository/domain.go +++ /dev/null @@ -1,31 +0,0 @@ -package repository - -import ( - "database/sql" - "time" -) - -type User struct { - Id int - GithubId int - GithubUsername string - Email string - AvatarUrl string - CurrentBalance int - CurrentActiveGoalId sql.NullInt64 - IsBlocked bool - IsAdmin bool - Password string - IsDeleted bool - DeletedAt sql.NullTime - CreatedAt time.Time - UpdatedAt time.Time -} - -type CreateUserRequestBody struct { - GithubId int - GithubUsername string - AvatarUrl string - Email string - IsAdmin bool -} diff --git a/internal/repository/user.go b/internal/repository/user.go deleted file mode 100644 index 284ce27e..00000000 --- a/internal/repository/user.go +++ /dev/null @@ -1,158 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "log/slog" - "time" - - "github.com/jmoiron/sqlx" - "github.com/joshsoftware/code-curiosity-2025/internal/pkg/apperrors" -) - -type userRepository struct { - BaseRepository -} - -type UserRepository interface { - RepositoryTransaction - GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) - GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error) - CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error) - UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error -} - -func NewUserRepository(db *sqlx.DB) UserRepository { - return &userRepository{ - BaseRepository: BaseRepository{db}, - } -} - -const ( - getUserByIdQuery = "SELECT * from users where id=$1" - - getUserByGithubIdQuery = "SELECT * from users where github_id=$1" - - createUserQuery = ` - INSERT INTO users ( - github_id, - github_username, - email, - avatar_url - ) - VALUES ($1, $2, $3, $4) - RETURNING *` - - updateEmailQuery = "UPDATE users SET email=$1, updated_at=$2 where id=$3" -) - -func (ur *userRepository) GetUserById(ctx context.Context, tx *sqlx.Tx, userId int) (User, error) { - executer := ur.BaseRepository.initiateQueryExecuter(tx) - - var user User - err := executer.QueryRowContext(ctx, getUserByIdQuery, userId).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - slog.Error("user not found", "error", err) - return User{}, apperrors.ErrUserNotFound - } - slog.Error("error occurred while getting user by id", "error", err) - return User{}, apperrors.ErrInternalServer - } - - return user, nil -} - -func (ur *userRepository) GetUserByGithubId(ctx context.Context, tx *sqlx.Tx, githubId int) (User, error) { - executer := ur.BaseRepository.initiateQueryExecuter(tx) - - var user User - err := executer.QueryRowContext(ctx, getUserByGithubIdQuery, githubId).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - slog.Error("user not found", "error", err) - return User{}, apperrors.ErrUserNotFound - } - slog.Error("error occurred while getting user by github id", "error", err) - return User{}, apperrors.ErrInternalServer - } - - return user, nil -} - -func (ur *userRepository) CreateUser(ctx context.Context, tx *sqlx.Tx, userInfo CreateUserRequestBody) (User, error) { - executer := ur.BaseRepository.initiateQueryExecuter(tx) - - var user User - err := executer.QueryRowContext(ctx, createUserQuery, - userInfo.GithubId, - userInfo.GithubUsername, - userInfo.Email, - userInfo.AvatarUrl, - ).Scan( - &user.Id, - &user.GithubId, - &user.GithubUsername, - &user.AvatarUrl, - &user.Email, - &user.CurrentActiveGoalId, - &user.CurrentBalance, - &user.IsBlocked, - &user.IsAdmin, - &user.Password, - &user.IsDeleted, - &user.DeletedAt, - &user.CreatedAt, - &user.UpdatedAt, - ) - if err != nil { - slog.Error("error occurred while creating user", "error", err) - return User{}, apperrors.ErrUserCreationFailed - } - - return user, nil - -} - -func (ur *userRepository) UpdateUserEmail(ctx context.Context, tx *sqlx.Tx, userId int, email string) error { - executer := ur.BaseRepository.initiateQueryExecuter(tx) - - _, err := executer.ExecContext(ctx, updateEmailQuery, email, time.Now(), userId) - if err != nil { - slog.Error("failed to update user email", "error", err) - return apperrors.ErrInternalServer - } - - return nil -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..69cee8fc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Code Curiosity - working copy", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}