diff --git a/.gitignore b/.gitignore index 088c460..f2b93cb 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ frontend/next-env.d.ts # Jetbrains IDE /.idea +backend/.env + +# testing +backend/resources/test \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a05202..fbc965c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,73 @@ { - "cSpell.words": [ - "cmds", - "coursepage", - "corepack", - "Darkspace", - "healthcheck", - "idnum", - "Lihua", - "netid", - "Taskfile", - "taskfiles", - "Vercel" - ] + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + }, + "[css]": { + "editor.defaultFormatter": "vscode.css-language-features", + "editor.formatOnSave": true, + "editor.suggest.insertMode": "replace", + "cSpell.fixSpellingWithRenameProvider": false + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": false, + }, + "go.toolsManagement.autoUpdate": true, + "[go]": { + "editor.insertSpaces": false, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "golang.go" + }, + "[markdown]": { + "editor.unicodeHighlight.ambiguousCharacters": false, + "editor.unicodeHighlight.invisibleCharacters": false, + "editor.wordWrap": "on", + "editor.quickSuggestions": { + "comments": "off", + "strings": "off", + "other": "off" + } + }, + "git.autofetch": true, + "editor.formatOnSave": true, + "cSpell.words": [ + "cmds", + "corepack", + "courseid", + "coursepage", + "Darkspace", + "dbname", + "excelize", + "godotenv", + "healthcheck", + "idnum", + "Lihua", + "netid", + "realip", + "sslmode", + "Taskfile", + "taskfiles", + "userid", + "Vercel" + ] } diff --git a/README.md b/README.md index 56124a6..2a0f88f 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ We are using ```yarn``` and NOT npm. Our database for the backend is SQL based. - Go - - Gin - pq - Postgresql - Nginx @@ -84,6 +83,18 @@ The backend directory is structured in this manner: ```remote``` contains Docker files and anything needed for deployment purposes, like setup scripts. +#### Understanding the Backend **internal** Package + +```internal``` has three packages inside it: + +- dal +- domain +- models + +```dal``` stands for Data Access Layer, and is what directly interfaces with any database implementation. ```domain``` contains services that interface with the ```dal``` package. + +#### Authentication vs Authorization + ## Getting Started This project uses [Taskfile](https://taskfile.dev) as a Makefile replacement. This is used to run tests and synchronize docker containers. Unless specified otherwise, all task commands must be run in the root directory of the project. @@ -104,6 +115,21 @@ There are several types of tasks, some of which are ```dev```, ```build```, ```t The frontend exists at http://localhost:3000/ and the backend exists at http://localhost:6789/api/v1/ +#### PostgreSQL Docker Database + +To connect directly to the database from the command-line, run the command: ```psql postgresql://{username}:{password}@{host}:{port}/{database name}```. The parameters in brackets are for you to set. + +A ```compose.yaml``` file exists in backend/remote, which defines a backend docker compose structure for the API and the PostgreSQL database. To run the database, execute ```task back:db-up``` in the root directory. + +To access the running container from the command line, do these series of steps: + +1. Run ```docker ps``` to see running containers' IDs. Find the entry for the container named ```db-postgres```. +2. If our container's ID was abcdef1234, run the command ```docker exec -it abdef1234 bash``` to enter the container's shell environment. +3. In the shell, enter the command ```su postgres``` to change the current user from root to postgres. Postgres cannot be accessed from root. +4. Now enter ```psql -U postgres```. This lets us into the postgresql command line environment. You can now execute psql commands. + +To exit out of this environment, type ```exit```. + ## Testing We must implement endpoint testing. @@ -117,20 +143,32 @@ We must implement endpoint testing. - [Setting up and using postgresql on Mac](https://www.sqlshack.com/setting-up-a-postgresql-database-on-mac/) - [Setting postgresql on Windows](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database#setting-up-postgresql-on-windows) +### Go + +- [Connecting to postgresql database](https://www.calhoun.io/connecting-to-a-postgresql-database-with-gos-database-sql-package/) + ### Postgresql - [Tuning postgresql for memory](https://www.enterprisedb.com/postgres-tutorials/how-tune-postgresql-memory) - [Postgresql tuner webapp](https://pgtune.leopard.in.ua/) +- [How to Create an identity column in Postgres](https://www.commandprompt.com/education/how-do-i-create-an-identity-column-in-postgresql/) ### Docker - [Running postgresql in a Docker container](https://www.docker.com/blog/how-to-use-the-postgres-docker-official-image/) - [Golang-Nginx-Postgres Docker Compose](https://github.com/docker/awesome-compose/tree/master/nginx-golang-postgres) +- [Init script for docker postgres](https://mkyong.com/docker/how-to-run-an-init-script-for-docker-postgres/) +- [Custom dockerfile for postgres container](https://forums.docker.com/t/how-to-make-a-docker-file-for-your-own-postgres-container/126526/8) +- [Docker compose env file](https://www.warp.dev/terminus/docker-compose-env-file) + +- [Docker credential desktop - executable not found in PATH](https://blog.saintmalik.me/docs/docker-credential-desktop/) + - As the article states: just edit your ~/.docker/config.json and remove the "credsStore" : "desktop" and leave the "credStore" : "desktop" ## Ideas - Invite students through link or code - Need auth for API routes +- Need to clarify what specific elements the frontend needs after calling API endpoints ## Glossary diff --git a/Taskfile.yml b/Taskfile.yml index 39e2f31..1d899ce 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,6 +9,7 @@ tasks: test: cmds: - task back:test + - task front:test # Starts dev servers for both frontend and backend dev: @@ -28,3 +29,14 @@ tasks: cmds: - task front:install - task back:tidy + + # DB up + dbu: + cmds: + - task back:db-up -s + dbd: + cmds: + - task back:db-down + dbdc: + cmds: + - task back:db-down-clean diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cd8299c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,50 @@ +# ======================================================================= # +# Storage Environment Variables # +# ======================================================================= # + +# LOCAL_STORAGE_DIRECTORY is the directory where the server will store +# local files, such as media, data, or templates. +LOCAL_STORAGE_DIRECTORY= + +# S3_TOKEN and S3_URL are both used to access a remote S3 object storage +# hosted by Amazon. +S3_TOKEN= +S3_URL= + +# EXCEL_TEMPLATE_PATH is the path of the excel template +EXCEL_TEMPLATE_PATH= + +# ======================================================================= # +# Postgresql Environment Variables # +# ======================================================================= # + +# DB_NAME is the name of the database in the postgresql instance. +# This will be used to connect to a database named "development". +# In a production environment, this would be different, according to +# the deployment name, whatever that may be. +DB_NAME=development + +# The postgresql image requires a set username and password to setup. +# Therefore, these are required if running development locally. They can +# be anything one wishes. In production environments though, its necessary +# to use the actual credentials that will be used to connect to the +# database. +DB_USERNAME= +DB_PASSWORD= + +# DB_HOST will usually be "localhost" in a development environment. +# The port may also be arbitrary. The port is the one on the host +# that docker will bind the container's port to. Sometimes, your +# machine might already have an instance of postgresql running without +# docker. Postgresql's default port is 5432. The example here sets the +# DB_PORT to 6543 to avoid this port collision. +DB_HOST=localhost +DB_PORT=6543 + +# This is a field that disables SSL mode to remove the error: +# "SSL is not enabled on this server/database" during development. +# In a production environment, this would be enabled. +DB_SSL_MODE=disable + +# URL Example +DB_DSN="postgres://postgres:password@localhost:6543/development" diff --git a/backend/cmd/api/application.go b/backend/cmd/api/application.go new file mode 100644 index 0000000..0e56e06 --- /dev/null +++ b/backend/cmd/api/application.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "database/sql" + "github.com/n30w/Darkspace/internal/dal" + "log" + "time" + + "github.com/n30w/Darkspace/internal/domain" + + // This import fixes the error: "unknown driver "postgres" (forgotten import?)" + _ "github.com/lib/pq" +) + +type config struct { + // Port the server will run on. + port int + + // Runtime environment, either "development", "staging", or "production". + env string + + // Database configurations + db dal.DBConfig + + // limiter is limiter information for rate limiting. + limiter struct { + // rps is requests per second. + rps float64 + burst int + + // enabled either disables or enables rate limiting altogether. + enabled bool + } +} + +// openDB opens a connection to the database using a certain config. +func openDB(cfg config) (*sql.DB, error) { + db, err := sql.Open(cfg.db.Driver, cfg.createDataSourceName()) + if err != nil { + return nil, err + } + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxOpenConns(cfg.db.MaxOpenConns) + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxIdleConns(cfg.db.MaxIdleConns) + + duration, err := time.ParseDuration(cfg.db.MaxIdleTime) + if err != nil { + return nil, err + } + + db.SetConnMaxIdleTime(duration) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.PingContext(ctx) + if err != nil { + return nil, err + } + + return db, nil +} + +// createDataSourceName creates the dataSourceName parameter of the +// sql.Open function. +func (cfg config) createDataSourceName() string { + return cfg.db.CreateDataSourceName() +} + +func (cfg config) SetFromEnv() { + cfg.db.SetFromEnv() +} + +type application struct { + config config + logger *log.Logger + services *domain.Service +} diff --git a/backend/cmd/api/context.go b/backend/cmd/api/context.go new file mode 100644 index 0000000..af0edb4 --- /dev/null +++ b/backend/cmd/api/context.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "github.com/n30w/Darkspace/internal/models" + "net/http" +) + +type contextKey string + +const userContextKey = contextKey("user") + +func (app *application) contextSetUser( + r *http.Request, + user *models.User, +) *http.Request { + ctx := context.WithValue(r.Context(), userContextKey, user) + return r.WithContext(ctx) +} + +func (app *application) contextGetUser(r *http.Request) *models.User { + user, ok := r.Context().Value(userContextKey).(*models.User) + if !ok { + panic("missing user value in request context") + } + + return user +} diff --git a/backend/cmd/api/errors.go b/backend/cmd/api/errors.go index 74d8b46..333acf8 100644 --- a/backend/cmd/api/errors.go +++ b/backend/cmd/api/errors.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "net/http" ) @@ -22,8 +23,119 @@ func (app *application) logError(r *http.Request, err error) { app.logger.Print(err) } +func (app *application) errorResponse( + w http.ResponseWriter, r *http.Request, + status int, message any, +) { + wrap := jsonWrap{"error": message} + + err := app.writeJSON(w, status, wrap, nil) + if err != nil { + app.logError(r, err) + w.WriteHeader(500) + } +} + // serverError returns a 400 Bad Request. This is called when JSON is messed up. -func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { +func (app *application) serverError( + w http.ResponseWriter, + r *http.Request, + err error, +) { app.logError(r, err) http.Error(w, err.Error(), http.StatusBadRequest) } + +// rateLimitExceededResponse returns a 429 Too Many Requests response. +func (app *application) rateLimitExceededResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "rate limit exceeded" + app.errorResponse(w, r, http.StatusTooManyRequests, message) +} + +func (app *application) invalidCredentialsResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "invalid authentication credentials" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) notFoundResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "the requested resource could not be found" + app.errorResponse(w, r, http.StatusNotFound, message) +} + +func (app *application) methodNotAllowedResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := fmt.Sprintf( + "the %s method is not supported for this resource", + r.Method, + ) + app.errorResponse(w, r, http.StatusMethodNotAllowed, message) +} + +func (app *application) badRequestResponse( + w http.ResponseWriter, + r *http.Request, + err error, +) { + app.errorResponse(w, r, http.StatusBadRequest, err.Error()) +} + +func (app *application) failedValidationResponse( + w http.ResponseWriter, + r *http.Request, + errors map[string]string, +) { + app.errorResponse(w, r, http.StatusUnprocessableEntity, errors) +} + +func (app *application) editConflictResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "unable to update the record due to an edit conflict, please try again" + app.errorResponse(w, r, http.StatusConflict, message) +} + +func (app *application) invalidAuthenticationTokenResponse( + w http.ResponseWriter, + r *http.Request, +) { + w.Header().Set("WWW-Authenticate", "Bearer") + + message := "invalid or missing authentication token" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) authenticationRequiredResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "you must be authenticated to access this resource" + app.errorResponse(w, r, http.StatusUnauthorized, message) +} + +func (app *application) inactiveAccountResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "your user account must be activated to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} + +func (app *application) notPermittedResponse( + w http.ResponseWriter, + r *http.Request, +) { + message := "your user account doesn't have the necessary permissions to access this resource" + app.errorResponse(w, r, http.StatusForbidden, message) +} diff --git a/backend/cmd/api/handler_test.go b/backend/cmd/api/handler_test.go new file mode 100644 index 0000000..db85b0b --- /dev/null +++ b/backend/cmd/api/handler_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "testing" +) + +func Test_HomeHandler(t *testing.T) { + + // requestBody := map[string]string{ + // "token": "your_token_here", + // } + + // jsonBody, err := json.Marshal(requestBody) + // if err != nil { + // t.Fatalf("Failed to marshal JSON: %v", err) + // } + + // // req := httptest.NewRequest(http.MethodPost, "/v1/home", bytes.NewBuffer(jsonBody)) + + // // res := httptest.NewRecorder() + + // // app.homeHandler() +} diff --git a/backend/cmd/api/handlers.go b/backend/cmd/api/handlers.go index 768cc24..93df07a 100644 --- a/backend/cmd/api/handlers.go +++ b/backend/cmd/api/handlers.go @@ -1,90 +1,151 @@ package main import ( + "errors" + "fmt" + "mime" "net/http" - models "github.com/n30w/Darkspace/internal/domain" + "path/filepath" + "time" + + "github.com/n30w/Darkspace/internal/dal" + "github.com/n30w/Darkspace/internal/models" ) +// An input struct is used for ushering in data because it makes it explicit +// as to what we are getting from the incoming request. + // homeHandler returns a set template of information needed for the home // page. // // REQUEST: Netid // RESPONSE: Active course data [name, 3 most recent assignments uncompleted, ] func (app *application) homeHandler(w http.ResponseWriter, r *http.Request) { + // Get user's enrolled courses + var input struct { + Token string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Home handler, token retrieved: %s...", input.Token) + + netId, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Home handler, netid:%s retrieved from token...", netId) + + courses, err := app.services.UserService.GetUserCourses(netId) + if err != nil { + app.logger.Printf("ERROR: %v", err) + app.serverError(w, r, err) + return + } + + app.logger.Printf("Home handler, retrieved courses: %v...", courses) + res := jsonWrap{"courses": courses} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + } } // courseHomepageHandler returns data related to the homepage of a course. // // REQUEST: course id -// RESPONSE: +// RESPONSE: course + banner image func (app *application) courseHomepageHandler( w http.ResponseWriter, r *http.Request, ) { - // id := r.PathValue("id") - // var course *models.Course - // var err error + id := r.PathValue("id") + app.logger.Printf("Course homepage handler, course id: %s retrieved...", id) - // course, err = app.models.Course.Get(id) - // if err != nil { - // app.serverError(w, r, err) - // } + course, err := app.services.CourseService.RetrieveCourse(id) + if err != nil { + app.serverError(w, r, err) + return + } - // res := jsonWrap{"course": course} + // Get the first couple people in the roster. + roster, err := app.services.CourseService.RetrieveRoster(id) + if err != nil { + app.serverError(w, r, err) + return + } - // err = app.writeJSON(w, http.StatusOK, res, nil) - // if err != nil { - // app.serverError(w, r, err) - // } + res := jsonWrap{"course": course, "roster": roster} - // If the course ID exists in the database AND the user requesting this - // data has the appropriate permissions, retrieve the course data requested. + app.logger.Printf("Course homepage handler, sending course and roster...") + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } } // createCourseHandler creates a course. -// -// REQUEST: course -// RESPONSE: course + course uuid func (app *application) courseCreateHandler( w http.ResponseWriter, r *http.Request, ) { - + app.logger.Printf("Course create handler...") var input struct { - Title string `json:"title"` - TeacherName string `json:"username"` + Title string `json:"title"` + Token string `json:"token"` } err := app.readJSON(w, r, &input) if err != nil { app.serverError(w, r, err) + return + } + + teacherid, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return } + app.logger.Printf("Course create handler, getting teacher id: %s from token...", teacherid) - var course *models.Course + teachers := []string{teacherid} - // Validate if there is a name associated with the course. - // We can also do a fuzzy match of course names. - course, err = app.models.Course.Get(input.Title) + course := &models.Course{ + Title: input.Title, + Teachers: teachers, + } - // If course already exists, send error. + course, err = app.services.CourseService.CreateCourse(course, teacherid) if err != nil { app.serverError(w, r, err) + return } + app.logger.Printf("Course create handler, created course: %v...", course) - // if not, proceed with course creation. - err = app.models.Course.Insert(course) + // Return success. + res := jsonWrap{"course": course} + err = app.writeJSON(w, http.StatusOK, res, nil) if err != nil { app.serverError(w, r, err) + return } - // Return success. } // courseReadHandler relays information back to the requester -// about a certain course. +// about a certain course. This is hit when the frontend requests +// the homepage of a specific course via ID. // // REQUEST: course ID // RESPONSE: course data @@ -92,74 +153,414 @@ func (app *application) courseReadHandler( w http.ResponseWriter, r *http.Request, ) { + + courseId := r.PathValue("id") + app.logger.Printf("Course read handler, course id: %s...", courseId) + + course, err := app.services.CourseService.RetrieveCourse(courseId) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"course": course} + + app.logger.Printf("Course read handler, sending course: %#v...", res) + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } } -// courseUpdateHandler updates information about a course. +// courseDeleteHandler deletes a course // -// REQUEST: course ID + fields to update -// RESPONSE: course -func (app *application) courseUpdateHandler( +// REQUEST: course ID, token +// RESPONSE: updated list of courses +func (app *application) courseDeleteHandler( + w http.ResponseWriter, + r *http.Request, +) { + courseid := r.PathValue("id") + token := r.Header.Get("Authorization") + + app.logger.Printf("Course delete handler, deleting course: %s, with user token: %s...", courseid, token) + + netId, err := app.services.AuthenticationService.GetNetIdFromToken(token) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Course delete handler, getting user by userid: %s...", netId) + + user, err := app.services.UserService.GetByID(netId) + if err != nil { + app.serverError(w, r, err) + return + } + + student := dal.Membership(0) + teacher := dal.Membership(1) + + if user.Membership == student { // if student, unenroll from course + app.logger.Printf("Course delete handler, unenrolling student from course...") + + err = app.services.UserService.UnenrollUserFromCourse(netId, courseid) // delete course from user + if err != nil { + app.serverError(w, r, err) + return + } + err = app.services.CourseService.RemoveFromRoster(courseid, netId) // delete user from course + if err != nil { + app.serverError(w, r, err) + return + } + + } else if user.Membership == teacher { // if teacher, delete course from database + app.logger.Printf("Course delete handler, deleting course from Darkspace...") + + err = app.services.CourseService.DeleteCourse(courseid) + if err != nil { + app.serverError(w, r, err) + return + } + } + + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// REQUEST: courseid + image file +// RESPONSE: status +func (app *application) bannerCreateHandler( w http.ResponseWriter, r *http.Request, ) { + app.logger.Printf("Banner create handler...") + + courseid := r.PathValue("mediaId") + // Limit upload size to 10MB + err := r.ParseMultipartForm(10 << 20) + if err != nil { + app.serverError(w, r, err) + return + } + + f, handler, err := r.FormFile("file") + if err != nil { + app.serverError(w, r, err) + return + } + + defer f.Close() + + ft := GetFileType(handler.Filename) + + if ft == models.NULL { + app.serverError(w, r, fmt.Errorf("invalid file type")) + return + } + + fileName := courseid + "_banner." + ft.String() + + // Save the file to disk + path, err := app.services.FileService.Save(fileName, f) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Banner create handler, saved banner: %s to disk...", fileName) + + // Create metadata and add to database + metadata := &models.Media{ + FileName: handler.Filename, + AttributionsByType: make(map[string]string), + FileType: ft, + FilePath: path, + } + + metadata.AttributionsByType["course"] = courseid + + _, err = app.services.MediaService.AddBanner(metadata) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Banner create handler, created banner: %v...", metadata) + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } } -// courseDeleteHandler deletes a course. -// -// REQUEST: course ID. -// RESPONSE: updated list of courses -func (app *application) courseDeleteHandler( +// REQUEST: banner id +// RESPONSE: banner image +func (app *application) bannerReadHandler( + w http.ResponseWriter, + r *http.Request, +) { + bannerId := r.PathValue("mediaId") + + app.logger.Printf("Banner read handler, received Banner ID: %s...", bannerId) + + banner, err := app.services.MediaService.GetMedia(bannerId) + if err != nil { + app.serverError(w, r, err) + } + + app.logger.Printf("Banner read handler, retrieved metadata: %v...", banner) + + // Set Content-Type header based on file extension + contentType := mime.TypeByExtension("." + banner.FileType.String()) + if contentType == "" { + contentType = "application/octet-stream" // Default content type + } + + app.logger.Printf("Banner read handler, setting content type to: %s...", contentType) + + contentDispositionValue := "inline" + + filePath := banner.FilePath + if filePath == "" { + banner.FilePath = app.services.FileService.Path() + "/defaults/" + banner.FileName + "." + banner.FileType.String() + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", contentDispositionValue) + + app.logger.Printf("Banner read handler, serving banner with file path: %s...", banner.FilePath) + + // Serve the file's content + http.ServeFile(w, r, banner.FilePath) +} + +// REQUEST: course ID, teacher ID, announcement description +// RESPONSE: announcement +func (app *application) announcementCreateHandler( + w http.ResponseWriter, + r *http.Request, +) { + cId := r.PathValue("id") + var input struct { + CourseId string `json:"courseid"` + Token string `json:"token"` + Title string `json:"title"` + Description string `json:"description"` + Media []string `json:"media"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + netId, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return + } + + //msg := &models.Message{ + // Post: models.Post{ + // Title: input.Title, + // Description: input.Description, + // Owner: netid, + // Media: input.Media, + // }, + // Type: true, + //} + // + //msg, err = app.services.MessageService.CreateMessage(msg, cId) + msg, err := app.services.MessageService.CreateAnnouncement( + input.Title, + input.Description, + netId, + cId, + ) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"announcement": msg} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// REQUEST: announcement ID +// RESPONSE: announcement +func (app *application) announcementReadHandler( + w http.ResponseWriter, + r *http.Request, +) { + courseId := r.PathValue("id") + + msgids, err := app.services.MessageService.RetrieveMessages(courseId) + if err != nil { + app.serverError(w, r, err) + return + } + + var msgs []models.Message + + for _, msgid := range msgids { + msg, err := app.services.MessageService.ReadMessage(msgid) + if err != nil { + app.serverError(w, r, err) + return + } + msgs = append(msgs, *msg) + } + + res := jsonWrap{"announcements": msgs} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// REQUEST: announcement ID, action (title, body), updated field +// RESPONSE: announcement +func (app *application) announcementUpdateHandler( + w http.ResponseWriter, + r *http.Request, +) { + var input struct { + CourseId string `json:"courseid"` + TeacherId string `json:"teacherid"` + MsgId string `json:"announcementid"` + Action string `json:"action"` + UpdatedField string `json:"updatedfield"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + msg, err := app.services.MessageService.UpdateMessage(input.MsgId, input.Action, input.UpdatedField) + if err != nil { + app.serverError(w, r, err) + return + } + res := jsonWrap{"announcement": msg} + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +func (app *application) announcementDeleteHandler( w http.ResponseWriter, r *http.Request, ) { + + announcementId := r.PathValue("announcementId") + + err := app.services.MessageService.DeleteMessage(announcementId) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Announcement delete handler, deleting announcement: %s...", announcementId) + + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } } // User handlers, deals with anything user side. // userCreateHandler creates a user. // -// REQUEST: email, password, full name, netid -// RESPONSE: home page +// REQUEST: email, password, full name, netid, membership +// RESPONSE: status func (app *application) userCreateHandler( w http.ResponseWriter, r *http.Request, ) { - // use credential validation + var input struct { + FullName string `json:"fullname"` + Password string `json:"password"` + Email string `json:"email"` + Netid string `json:"netid"` + Membership int `json:"membership"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + // Map the input fields to the appropriate credentials fields. + c := models.Credentials{ + Username: app.services.UserService.NewUsername(input.Netid), + Password: app.services.UserService.NewPassword(input.Password), + Email: app.services.UserService.NewEmail(input.Email), + Membership: app.services.UserService.NewMembership(input.Membership), + } + + user, err := models.NewUser(input.Netid, c, input.FullName) + if err != nil { + app.serverError(w, r, err) + return + } + + err = app.services.UserService.CreateUser(user) + if err != nil { + app.serverError(w, r, err) + return + } } // userReadHandler reads a specific user's data, // which is specified by the requester. // -// REQUEST: user uuid +// REQUEST: user netid // RESPONSE: user func (app *application) userReadHandler( w http.ResponseWriter, r *http.Request, ) { - // Create a new user model, this will be used to perform SQL actions. + id := r.PathValue("id") - // Create a new User to read the JSON into. - u := models.User{} + var err error + var user *models.User - // read the JSON from the client into the User Model. - err := app.readJSON(w, r, &u) + // Perform a database lookup of user. + user, err = app.services.UserService.GetByID(id) + user, err = app.services.UserService.GetByID(id) if err != nil { app.serverError(w, r, err) + return } - // Perform a database lookup of user. + res := jsonWrap{"user": user} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } - // If user exists, read permissions of the user using accessControl, - // then send back the information requested. - //if u.perms[SELF].read { - // // Send back the requested user's bio/media/name/email, - // // by first wrapping the JSON into a readable map, - // // then writing to the http writer stream. - // d := jsonWrap{} - // err := app.writeJSON(w, http.StatusOK, d, nil) - //} else { - // app.serverError(w, r, http.ErrHandlerTimeout) - //} } // userUpdateHandler updates a user's data. @@ -170,6 +571,8 @@ func (app *application) userUpdateHandler( w http.ResponseWriter, r *http.Request, ) { + id := r.PathValue("id") + fmt.Printf("id: %s", id) } // userDeleteHandler deletes a user. A request must come from @@ -181,28 +584,14 @@ func (app *application) userDeleteHandler( w http.ResponseWriter, r *http.Request, ) { -} -// userPostHandler handles post requests. When a user posts -// something to a discussion, this is the handler that is called. -// A post consists of a body, media, and author. The request therefore -// requires an author of who posted it, what discussion it exists under, -// and if it is a reply or not. To find the author of who sent it, -// we can check with middleware authorization headers. -// -// REQUEST: user post -// RESPONSE: user post -func (app *application) userPostHandler( - w http.ResponseWriter, - r *http.Request, -) { } // userLoginHandler handles login requests from any user. It requires // a username and a password. A login must occur from a genuine domain. This // means that the request comes from the frontend server rather than the // user's browser. Written to the http response is an authorized -// login cookie. +// login token. // // REQUEST: username/email, password // RESPONSE: auth cookie/login session @@ -210,98 +599,784 @@ func (app *application) userLoginHandler( w http.ResponseWriter, r *http.Request, ) { + var noToken bool + var input struct { + NetId string `json:"netid"` + Password string `json:"password"` + } -} + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } -// Assignment handlers. Only teachers should be able to request the use of -// these handlers. Therefore, teacher permission/authorization is -// a necessity. + // Validate credentials. + err = app.services.UserService.ValidateUser(input.NetId, input.Password) + if err != nil { + app.serverError(w, r, err) + return + } -// assignmentCreateHandler creates an assignment based on the request values. -// To create an assignment, a request must contain an assignment: title, -// author, body, and media. The return value is the assignment data along -// with a uuid. -// -// REQUEST: title, author, body, media -// RESPONSE: assignment -func (app *application) assignmentCreateHandler( - w http.ResponseWriter, - r *http.Request, -) { -} + app.logger.Printf("user validated") + + // If token exists, return token: + token, err := app.services.AuthenticationService.RetrieveToken(input.NetId) + if err != nil { + switch { + case errors.Is(err, dal.ERR_RECORD_NOT_FOUND): + app.logger.Printf("user token not found for %s", input.NetId) + noToken = true + default: + app.serverError(w, r, err) + return + } + } + + if noToken { + app.logger.Printf("generating user token...") + token, err = app.services.AuthenticationService.NewToken(input.NetId) + } + + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Token: %v", token) + + membership, err := app.services.UserService.GetMembership(input.NetId) + if err != nil { + app.serverError(w, r, err) + return + } + + wrapped := jsonWrap{"authentication_token": token, "permissions": membership} + + err = app.writeJSON( + w, http.StatusCreated, + wrapped, nil, + ) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// Assignment handlers. Only teachers should be able to request the use of +// these handlers. Therefore, teacher permission/authorization is +// a necessity. + +// assignmentCreateHandler creates an assignment based on the request values. +// To create an assignment, a request must contain an assignment: title, +// author, body, and media. The return value is the assignment data along +// with a uuid. +// +// REQUEST: title, author, body, media +// RESPONSE: assignment +func (app *application) assignmentCreateHandler( + w http.ResponseWriter, + r *http.Request, +) { + app.logger.Printf("Creating assignment...") + var input struct { + Title string `json:"title"` + Token string `json:"token"` + Description string `json:"description"` + Media []string `json:"media"` + DueDate string `json:"duedate"` + CourseId string `json:"courseid"` + } + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + netid, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return + } + + post := models.Post{ + Title: input.Title, + Description: input.Description, + Owner: netid, + Media: input.Media, + Course: input.CourseId, + } + + dueDate, err := time.Parse("2006-01-02", input.DueDate) + + if err != nil { + app.serverError(w, r, err) + return + } + + assignment := &models.Assignment{ + Post: post, + DueDate: dueDate, + } + + assignment, err = app.services.AssignmentService.CreateAssignment(assignment) + if err != nil { + app.serverError(w, r, err) + return + } + res := jsonWrap{"assignment": assignment} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + +} // assignmentReadHandler relays assignment data back to the requester. To read // one specific assignment, one must only request the UUID of an assignment. // // REQUEST: uuid -// RESPONSE: assignment +// RESPONSE: assignments func (app *application) assignmentReadHandler( w http.ResponseWriter, r *http.Request, ) { + var input struct { + AssignmentId string `json:"assignment_id"` + Token string `json:"token"` + } + + courseId := r.PathValue("courseId") + + switch r.Method { + // Retrieve multiple assignments if its a single GET request. + case http.MethodGet: + assignmentIds, err := app.services.AssignmentService.RetrieveAssignments(courseId) + if err != nil { + app.serverError(w, r, err) + return + } + + var assignments []models.Assignment + + for _, id := range assignmentIds { + assignment, err := app.services.AssignmentService.ReadAssignment(id) + if err != nil { + app.serverError(w, r, err) + return + } + assignments = append(assignments, *assignment) + } + + res := jsonWrap{"assignments": assignments} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + + // If it's a post request, we can expect an input and body. Therefore, + // only retrieve a single Assignment. + case http.MethodPost: + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + assignment, err := app.services.AssignmentService.ReadAssignment( + input. + AssignmentId, + ) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"assignment": assignment} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + return + + // Bad dog. + default: + app.serverError(w, r, fmt.Errorf("method %s not allowed", r.Method)) + return + } } // assignmentUpdateHandler updates the information of an assignment. // -// REQUEST: uuid +// REQUEST: uuid, updated information, type (title, description, duedate) // RESPONSE: assignment func (app *application) assignmentUpdateHandler( w http.ResponseWriter, r *http.Request, ) { + var input struct { + Uuid string `json:"uuid"` + UpdatedField interface{} `json:"updatedfield"` + Action string `json:"action"` + } + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + assignment, err := app.services.AssignmentService.UpdateAssignment(input.Uuid, input.UpdatedField, input.Action) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"assignment": assignment} + + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } } // assignmentDeleteHandler deletes an assignment. // -// REQUEST: uuid -// RESPONSE: 200 OK +// REQUEST: assignmentId +// RESPONSE: updated list of assignments func (app *application) assignmentDeleteHandler( w http.ResponseWriter, r *http.Request, ) { + assignmentid := r.PathValue("assignmentId") + + err := app.services.AssignmentService.DeleteAssignment(assignmentid) + if err != nil { + app.serverError(w, r, err) + return + } + + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } + } -// discussionCreateHandler creates a discussion. -// -// REQUEST: where (the discussion is being created), title, body, media, poster -// RESPONSE: discussion data -func (app *application) discussionCreateHandler( +func (app *application) assignmentMediaUploadHandler( w http.ResponseWriter, r *http.Request, ) { + assignmentid := r.PathValue("id") + // Parse the multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB maximum form size + if err != nil { + app.serverError(w, r, err) + return + } + + // Retrieve the file(s) from the form + files := r.MultipartForm.File["files"] + for _, fileHeader := range files { + // Open the uploaded file + file, err := fileHeader.Open() + if err != nil { + app.serverError(w, r, err) + return + } + defer file.Close() + path, err := app.services.FileService.Save(fileHeader.Filename, file) + if err != nil { + app.serverError(w, r, err) + return + } + media := &models.Media{ + FileName: fileHeader.Filename, + AttributionsByType: make(map[string]string), + FileType: GetFileType(fileHeader.Filename), + FilePath: path, + } + media.AttributionsByType["assignment"] = assignmentid + media, err = app.services.MediaService.AddAssignmentMedia(media) + if err != nil { + app.serverError(w, r, err) + } + } + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } } -// discussionReadHandler reads a discussion. -// -// REQUEST: discussion uuid -// RESPONSE: discussion -func (app *application) discussionReadHandler( +// REQUEST: media id +// RESPONSE: assignment media +func (app *application) mediaDownloadHandler( w http.ResponseWriter, r *http.Request, ) { + mediaid := r.PathValue("mediaId") + media, err := app.services.MediaService.GetMedia(mediaid) + if err != nil { + app.serverError(w, r, err) + } + file, err := app.services.FileService.GetFile(media.FilePath) + if err != nil { + app.serverError(w, r, err) + } + defer file.Close() + + // Get file information (size and name) + fileInfo, err := file.Stat() + if err != nil { + app.serverError(w, r, err) + return + } + + // Set Content-Type header based on file extension + contentType := mime.TypeByExtension(filepath.Ext(fileInfo.Name())) + if contentType == "" { + contentType = "application/octet-stream" // Default content type + } + contentDisposition := fmt.Sprintf(`attachment; filename="%s"`, fileInfo.Name()) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", contentDisposition) + + // Serve the file's content + // http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file) + http.ServeFile(w, r, media.FilePath) } -// discussionUpdateHandler updates a discussion's information. For example, -// the title or body or media and author. +// Submission handlers // -// REQUEST: discussion uuid + information to update -// RESPONSE: discussion -func (app *application) discussionUpdateHandler( +// REQUEST: assignmentid + userid +// RESPONSE: submission +func (app *application) submissionCreateHandler( w http.ResponseWriter, r *http.Request, ) { + assignmentid := r.PathValue("assignmentId") + + var input struct { + Token string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + + userId, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return + } + + submission := &models.Submission{ + AssignmentId: assignmentid, + User: models.User{ + Entity: models.Entity{ + ID: userId, + }, + }, + } + + assignment, err := app.services.AssignmentService.ReadAssignment(assignmentid) + if err != nil { + app.serverError(w, r, err) + return + } + submission.OnTime = submission.IsOnTime(assignment.DueDate) + app.logger.Printf("Submission create handler, creating submission: %+v...", submission) + + // Add submission into database and return submission with ID + submission, err = app.services.SubmissionService.CreateSubmission(submission) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"submission": submission} // Return submission + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } } -// discussionDeleteHandler deletes a discussion. -// -// REQUEST: discussion uuid -// RESPONSE: 200 or 500 response -func (app *application) discussionDeleteHandler( +// teachersubmissionReadHandler reads a submission from teacher view +// REQUEST: netid + assignmentid +// RESPONSE: submission +func (app *application) teachersubmissionReadHandler( + w http.ResponseWriter, + r *http.Request, +) { + assignmentId := r.PathValue("assignmentId") + userId := r.PathValue("userId") + app.logger.Printf("Teacher submission read handler, reading student (%s) submission for assignment: %s as teacher...", userId, assignmentId) + + submission, err := app.services.SubmissionService.GetUserSubmission(userId, assignmentId) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"submission": submission} // Return submission + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + +} + +// StudentsubmissionReadHandler reads a submission from student view +// REQUEST: assignmentid + token +// RESPONSE: submission +func (app *application) studentsubmissionReadHandler( + w http.ResponseWriter, + r *http.Request, +) { + assignmentId := r.PathValue("assignmentId") + + var input struct { + Token string `json:"token"` + } + + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Student submission read handler, getting token: %s and assignment id: %s...", input.Token, assignmentId) + + userId, err := app.services.AuthenticationService.GetNetIdFromToken(input.Token) + if err != nil { + app.serverError(w, r, err) + return + } + + submission, err := app.services.SubmissionService.GetUserSubmission(userId, assignmentId) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Student submission read handler, got student's submission: %+v", submission) + + res := jsonWrap{"submission": submission} // Return submission + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + +} + +// SubmissionUpdateHandler handles multiple submisisons +// REQUEST: submission id + feedback + grade +func (app *application) submissionUpdateHandler( + w http.ResponseWriter, + r *http.Request, +) { + submissionid := r.PathValue("id") + app.logger.Printf("Submission update handler, updating submission: %s...", submissionid) + var input struct { + Grade int `json:"grade"` + Feedback string `json:"feedback"` + } + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Submission update handler, grading submission with grade: %d and feedback: %s...", input.Grade, input.Feedback) + + submission, err := app.services.SubmissionService.GradeSubmission(input.Grade, input.Feedback, submissionid) + if err != nil { + app.serverError(w, r, err) + return + } + res := jsonWrap{"submission": submission} // Return submission + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// SubmissionDeleteHandler deletes a submission +// REQUEST: submissionid +// RESPONSE: 200 or 404 +func (app *application) submissionDeleteHandler( + w http.ResponseWriter, + r *http.Request, +) { + submissionid := r.PathValue("id") + + err := app.services.SubmissionService.DeleteSubmission(submissionid) + if err != nil { + app.serverError(w, r, err) + return + } + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +func (app *application) submissionMediaUploadHandler( + w http.ResponseWriter, + r *http.Request, +) { + submissionid := r.PathValue("id") + + app.logger.Printf("Submission media upload handler, uploading submission media to submissionid: %s...", submissionid) + + // Parse the multipart form + err := r.ParseMultipartForm(10 << 20) // 10 MB maximum form size + if err != nil { + app.serverError(w, r, err) + return + } + + // Retrieve the file(s) from the form + files := r.MultipartForm.File["files"] + for _, fileHeader := range files { + // Open the uploaded file + + file, err := fileHeader.Open() + if err != nil { + app.serverError(w, r, err) + return + } + defer file.Close() + fileName := fileHeader.Filename + path, err := app.services.FileService.Save(fileName, file) + if err != nil { + app.serverError(w, r, err) + return + } + media := &models.Media{ + FileName: fileHeader.Filename, + AttributionsByType: make(map[string]string), + FileType: GetFileType(fileHeader.Filename), + FilePath: path, + } + media.AttributionsByType["submission"] = submissionid + media, err = app.services.MediaService.AddSubmissionMedia(media) + if err != nil { + app.serverError(w, r, err) + } + } + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +func (app *application) addStudentHandler( + w http.ResponseWriter, + r *http.Request, +) { + var input struct { + NetId string `json:"netid"` + CourseId string `json:"courseid"` + } + err := app.readJSON(w, r, &input) + if err != nil { + app.serverError(w, r, err) + return + } + user, err := app.services.UserService.GetByID(input.NetId) + if err != nil { + app.serverError(w, r, err) + return + } + + for _, course := range user.Courses { + if course == input.CourseId { + res := jsonWrap{"response": "User is already enrolled"} + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } + } + } + + // User is not enrolled in the course + _, err = app.services.CourseService.AddToRoster(input.CourseId, input.NetId) + if err != nil { + app.serverError(w, r, err) + return + } + + res := jsonWrap{"response": "user successfully added to course"} + err = app.writeJSON(w, http.StatusOK, res, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +func (app *application) deleteStudentHandler( + w http.ResponseWriter, + r *http.Request, +) { + netId := r.PathValue("netId") + courseId := r.PathValue("courseId") + + err := app.services.CourseService.RemoveFromRoster(courseId, netId) + if err != nil { + app.serverError(w, r, err) + return + } + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// sendOfflineTemplate receives a request from a user about +// an offline grading template. It then prepares the template and sends +// it back to the client who requested it. +func (app *application) sendOfflineTemplate( w http.ResponseWriter, r *http.Request, ) { + var path string + + courseId := r.PathValue("id") + assignmentId := r.PathValue("post") + + app.logger.Printf("Send offline template, retrieving submissions with assignment id: %s and course id: %s", assignmentId, courseId) + + // Get submissions of this assignment from database. + submissions, err := app.services.SubmissionService.GetSubmissions(assignmentId) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Send offline template, retrieved submissions: %v", submissions) + + // Generate file name for Excel. + fileName := fmt.Sprintf("submissions_%s_%s.xlsx", courseId, assignmentId) + + path = app.services.FileService.Path() + + // Prepare the Excel file for transit. WriteSubmissions + // saves the file to where it needs to be saved. + path, err = app.services.ExcelService.WriteSubmissions( + path, + fileName, + submissions, + ) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Send offline template, saved excel file to %s", path) + // With this path, send over the file. + w.Header().Set( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheet", + ) + + headerValue := fmt.Sprintf(`attachment; filename="%s.xlsx"`, fileName) + + w.Header().Set( + "Content-Disposition", + headerValue, + ) + + err = app.services.ExcelService.SendFile(path, w) + if err != nil { + app.serverError(w, r, err) + return + } +} + +// addOfflineGrading will receive an incoming template and will +// sort the itemized template submissions and input them into +// the database +func (app *application) receiveOfflineGrades( + w http.ResponseWriter, + r *http.Request, +) { + app.logger.Printf("Receive offline grades...") + + // Limits the upload size to 10MB. + err := r.ParseMultipartForm(10 << 20) + if err != nil { + app.serverError(w, r, err) + return + } + + f, handler, err := r.FormFile("files") + if err != nil { + app.serverError(w, r, err) + return + } + + defer f.Close() + + // Check file type. + + // Save the file to disk. + path, err := app.services.FileService.Save(handler.Filename, f) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Receive offline grades, saving excel file to disk with path: %s...", path) + + // Get the submissions from the Excel file via path. + submissions, err := app.services.ExcelService.ReadSubmissions(path) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Receive offline grades, retrieving submissions from excel file :%+v", submissions) + + // Update the submission records in the database. + err = app.services.SubmissionService.UpdateSubmissions(submissions) + if err != nil { + app.serverError(w, r, err) + return + } + + app.logger.Printf("Receive offline grades, updated submissions...") + + for _, submission := range submissions { + sub, err := app.services.SubmissionService.GetSubmission(submission.ID) + if err != nil { + app.serverError(w, r, err) + return + } + app.logger.Printf("Updated submission grade: %f and feedback: %s", sub.Grade, sub.Feedback) + } + // All is well. + err = app.writeJSON(w, http.StatusOK, nil, nil) + if err != nil { + app.serverError(w, r, err) + return + } } diff --git a/backend/cmd/api/helpers.go b/backend/cmd/api/helpers.go index 5a3e251..7c3a9ff 100644 --- a/backend/cmd/api/helpers.go +++ b/backend/cmd/api/helpers.go @@ -1,15 +1,15 @@ package main import ( - "context" - "database/sql" "encoding/json" "errors" "fmt" "io" "net/http" "strings" - "time" + "github.com/n30w/Darkspace/internal/models" + "path/filepath" + ) // jsonWrap wraps a json message response before it gets sent out. @@ -135,7 +135,11 @@ func (app *application) writeJSON( // Add headers, then write to the output stream. w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - w.Write(js) + _, err = w.Write(js) + + if err != nil { + return err + } return nil } @@ -155,22 +159,25 @@ func jsonBuilder(data any) ([]byte, error) { return js, nil } -// Database helpers - -// openDB opens a connection to the database using a certain config. -func openDB(cfg config) (*sql.DB, error) { - db, err := sql.Open(cfg.db.driver, cfg.db.dsn) - if err != nil { - return nil, err - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err = db.PingContext(ctx) - if err != nil { - return nil, err - } - - return db, nil -} +// File Helpers +func GetFileType(filename string) models.FileType { + extension := strings.ToLower(filepath.Ext(filename)) + switch extension { + case ".jpg", ".jpeg": + return models.JPG + case ".png": + return models.PNG + case ".pdf": + return models.PDF + case ".m4a": + return models.M4A + case ".mp3": + return models.MP3 + case ".txt": + return models.TXT + case ".xlsx": + return models.XLSX + default: + return models.NULL + } +} \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index a86021f..fb75161 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -2,40 +2,21 @@ package main import ( "flag" - "fmt" "log" - "net/http" "os" - "time" - "github.com/n30w/Darkspace/internal/dao" + "github.com/joho/godotenv" + "github.com/n30w/Darkspace/internal/dal" + "github.com/n30w/Darkspace/internal/domain" ) const version = "1.0.0" -type config struct { - // Port the server will run on. - port int - - // Runtime environment, either "development", "staging", or "production". - env string - - // Database configurations - db struct { - // Database driver and DataSourceName - driver string - dsn string - } -} - -type application struct { - config config - logger *log.Logger - models *dao.Models -} - func main() { - + err := godotenv.Load("../../.env") + if err != nil { + log.Fatal("Error loading .env file") + } var cfg config flag.IntVar(&cfg.port, "port", 6789, "API server port") @@ -46,9 +27,59 @@ func main() { "Environment (development|staging|production)", ) + // Database driver. + flag.StringVar(&cfg.db.Dsn, "db-dsn", os.Getenv("DB_DSN"), "PostgreSQL DSN") + + // Database configuration for connection settings. + flag.IntVar( + &cfg.db.MaxOpenConns, "db-max-open-conns", 25, + "PostgreSQL max open connections", + ) + flag.IntVar( + &cfg.db.MaxIdleConns, "db-max-idle-conns", 25, + "PostgreSQL max idle connections", + ) + flag.StringVar( + &cfg.db.MaxIdleTime, "db-max-idle-time", "15m", + "PostgreSQL max connection idle time", + ) + + // Rate limiter configurations. + flag.Float64Var( + &cfg.limiter.rps, + "limiter-rps", + 2, + "Rate limiter maximum requests per second", + ) + flag.IntVar( + &cfg.limiter.burst, + "limiter-burst", + 4, + "Rate limiter maximum burst", + ) + flag.BoolVar( + &cfg.limiter.enabled, + "limiter-enabled", + true, + "Enable rate limiter", + ) + flag.Parse() - logger := log.New(os.Stdout, "", log.Ldate|log.Ltime) + logger := log.New(os.Stdout, "[DKSE] ", log.Ldate|log.Ltime) + + cfg.db.Driver = "postgres" + + // Set config database parameters via environment variables. + // cfg.SetFromEnv() + + cfg.db.Dsn = os.Getenv("DB_DSN") + cfg.db.Name = os.Getenv("DB_NAME") + cfg.db.Username = os.Getenv("DB_USERNAME") + cfg.db.Password = os.Getenv("DB_PASSWORD") + cfg.db.Host = os.Getenv("DB_HOST") + cfg.db.Port = os.Getenv("DB_PORT") + cfg.db.SslMode = os.Getenv("DB_SSL_MODE") db, err := openDB(cfg) if err != nil { @@ -57,23 +88,19 @@ func main() { defer db.Close() - app := &application{ - config: cfg, - logger: logger, - models: dao.NewModels(db), - } + volume := os.Getenv("LOCAL_STORAGE_DIRECTORY") - server := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.port), - Handler: app.routes(), - IdleTimeout: time.Minute, - ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, - } + store := dal.NewStore(db) + fileStore := dal.NewLocalVolume(volume) - logger.Printf("starting %s server on %s", cfg.env, server.Addr) + excelStore := dal.NewExcelStore(fileStore.Template()) - err = server.ListenAndServe() + app := &application{ + config: cfg, + logger: logger, + services: domain.NewServices(store, excelStore, fileStore), + } + err = app.server() logger.Fatal(err) } diff --git a/backend/cmd/api/middleware.go b/backend/cmd/api/middleware.go new file mode 100644 index 0000000..7356077 --- /dev/null +++ b/backend/cmd/api/middleware.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/tomasen/realip" + "golang.org/x/time/rate" +) + +func (app *application) recoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connect", "close") + app.serverError(w, r, fmt.Errorf("%s", err)) + } + }() + }, + ) +} + +// rateLimit limits the rate of requests using the golang.org/x/time/rate +// package. It also handles race conditions. +func (app *application) rateLimit(next http.Handler) http.Handler { + type client struct { + limiter *rate.Limiter + lastSeen time.Time + } + + var ( + mu sync.Mutex + clients = make(map[string]*client) + ) + + // Creates a go routine that checks when a client was last seen, + // so that we can delete old clients who haven't been seen in a + // while. + go func() { + for { + time.Sleep(time.Minute) + + mu.Lock() + + for ip, client := range clients { + if time.Since(client.lastSeen) > 3*time.Minute { + delete(clients, ip) + } + } + + mu.Unlock() + } + }() + + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if app.config.limiter.enabled { + ip := realip.FromRequest(r) + + mu.Lock() + + if _, found := clients[ip]; !found { + clients[ip] = &client{ + limiter: rate.NewLimiter( + rate.Limit(app.config.limiter.rps), + app.config.limiter.burst, + ), + } + } + + clients[ip].lastSeen = time.Now() + + if !clients[ip].limiter.Allow() { + mu.Unlock() + app.rateLimitExceededResponse(w, r) + return + } + + mu.Unlock() + } + + next.ServeHTTP(w, r) + }, + ) +} + +func (app *application) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set( + "Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS", + ) + w.Header().Set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization", + ) + w.Header().Set("Access-Control-Allow-Credentials", "true") + + // If the request is for the OPTIONS method, return immediately with a 200 status + // as this is a preflight request + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }, + ) +} + +//func (app *application) authenticate(next http.Handler) http.Handler { +// return http.HandlerFunc( +// func(w http.ResponseWriter, r *http.Request) { +// w.Header().Add("Vary", "Authorization") +// +// authorizationHeader := r.Header.Get("Authorization") +// +// if authorizationHeader == "" { +// r = app.contextSetUser(r, data.AnonymousUser) +// next.ServeHTTP(w, r) +// return +// } +// +// headerParts := strings.Split(authorizationHeader, " ") +// if len(headerParts) != 2 || headerParts[0] != "Bearer" { +// app.invalidAuthenticationTokenResponse(w, r) +// return +// } +// +// token := headerParts[1] +// +// v := validator.New() +// +// if data.ValidateTokenPlaintext(v, token); !v.Valid() { +// app.invalidAuthenticationTokenResponse(w, r) +// return +// } +// +// user, err := app.models.Users.GetForToken( +// data.ScopeAuthentication, +// token, +// ) +// if err != nil { +// switch { +// case errors.Is(err, data.ErrRecordNotFound): +// app.invalidAuthenticationTokenResponse(w, r) +// default: +// app.serverError(w, r, err) +// } +// return +// } +// +// r = app.contextSetUser(r, user) +// +// next.ServeHTTP(w, r) +// }, +// ) +//} +// +//func (app *application) requireAuthenticatedUser(next http.HandlerFunc) http.HandlerFunc { +// return http.HandlerFunc( +// func(w http.ResponseWriter, r *http.Request) { +// user := app.contextGetUser(r) +// +// if user.IsAnonymous() { +// app.authenticationRequiredResponse(w, r) +// return +// } +// +// next.ServeHTTP(w, r) +// }, +// ) +//} diff --git a/backend/cmd/api/operations.go b/backend/cmd/api/operations.go deleted file mode 100644 index e59cd0e..0000000 --- a/backend/cmd/api/operations.go +++ /dev/null @@ -1,6 +0,0 @@ -package main - -// Operations contains operations, in other words, business logic, -// stuff that makes stuff happen. - -func createCourse() {} diff --git a/backend/cmd/api/routes.go b/backend/cmd/api/routes.go index 35f3f25..13f0407 100644 --- a/backend/cmd/api/routes.go +++ b/backend/cmd/api/routes.go @@ -11,38 +11,129 @@ func (app *application) routes() *http.ServeMux { router := http.NewServeMux() router.HandleFunc("GET /v1/healthcheck", app.healthcheckHandler) - router.HandleFunc("GET /v1/home", app.homeHandler) - router.HandleFunc("GET /v1/course/{id}", app.courseHomepageHandler) + router.HandleFunc("POST /v1/home", app.homeHandler) + router.HandleFunc("GET /v1/course/{id}/homepage", app.courseHomepageHandler) + + router.HandleFunc( + "POST /v1/course/{id}/announcement/create", + app.announcementCreateHandler, + ) + router.HandleFunc( + "POST /v1/course/announcement/update", + app.announcementUpdateHandler, + ) + router.HandleFunc( + "DELETE /v1/course/announcement/{announcementId}/delete", + app.announcementDeleteHandler, + ) + // ID is message ID + router.HandleFunc( + "GET /v1/course/{id}/announcement/read", + app.announcementReadHandler, + ) + router.HandleFunc("POST /v1/course/addstudent", app.addStudentHandler) + router.HandleFunc("DELETE /v1/course/{courseId}/{netId}/deletestudent", app.deleteStudentHandler) // Course CRUD operations - router.HandleFunc("/v1/course/create", app.courseCreateHandler) - router.HandleFunc("/v1/course/read", app.courseReadHandler) - router.HandleFunc("/v1/course/update", app.courseUpdateHandler) - router.HandleFunc("/v1/course/delete", app.courseDeleteHandler) + router.HandleFunc("POST /v1/course/create", app.courseCreateHandler) + router.HandleFunc("GET /v1/course/{id}/read/", app.courseReadHandler) + router.HandleFunc("DELETE /v1/course/{id}/delete", app.courseDeleteHandler) - // User CRUD operations - router.HandleFunc("/v1/user/create", app.userCreateHandler) - router.HandleFunc("/v1/user/read", app.userReadHandler) - router.HandleFunc("/v1/user/update", app.userUpdateHandler) - router.HandleFunc("/v1/user/delete", app.userDeleteHandler) + router.HandleFunc( + "POST /v1/course/{mediaId}/banner/create", + app.bannerCreateHandler, + ) + router.HandleFunc( + "GET /v1/course/{mediaId}/banner/read", + app.bannerReadHandler, + ) - // A user posts something to a discussion - router.HandleFunc("/v1/user/post", app.userPostHandler) + // User CRUD operations + router.HandleFunc("POST /v1/user/create", app.userCreateHandler) + router.HandleFunc("GET /v1/user/read/{id}", app.userReadHandler) + router.HandleFunc("PATCH /v1/user/update/{id}", app.userUpdateHandler) + router.HandleFunc("DELETE /v1/user/delete/{id}", app.userDeleteHandler) // Login will require authorization, body will contain the credential info - router.HandleFunc("/v1/user/login", app.userLoginHandler) + router.HandleFunc("POST /v1/user/login", app.userLoginHandler) // Assignment CRUD operations - router.HandleFunc("/v1/course/assignment/create", app.assignmentCreateHandler) - router.HandleFunc("/v1/course/assignment/read", app.assignmentReadHandler) - router.HandleFunc("/v1/course/assignment/update", app.assignmentUpdateHandler) - router.HandleFunc("/v1/course/assignment/delete", app.assignmentDeleteHandler) - - // Discussion CRUD operations - router.HandleFunc("/v1/course/discussion/create", app.discussionCreateHandler) - router.HandleFunc("/v1/course/discussion/read", app.discussionReadHandler) - router.HandleFunc("/v1/course/discussion/update", app.discussionUpdateHandler) - router.HandleFunc("/v1/course/discussion/delete", app.discussionDeleteHandler) + router.HandleFunc( + "POST /v1/course/assignment/create", + app.assignmentCreateHandler, + ) + router.HandleFunc( + "GET /v1/course/{courseId}/assignment/read", + app.assignmentReadHandler, + ) + router.HandleFunc( + "PATCH /v1/course/assignment/update", + app.assignmentUpdateHandler, + ) + router.HandleFunc( + "DELETE /v1/course/assignment/{assignmentId}/delete", + app.assignmentDeleteHandler, + ) + + // app.assignmentReadHandler switches its behavior based on the HTTP Method. + router.HandleFunc( + "/v1/course/{courseId}/assignment/read", + app.assignmentReadHandler, + ) + + //router.HandleFunc( + // "POST /v1/course/assignment/{id}/upload", + // app.assignmentMediaUploadHandler, + //) + router.HandleFunc( + "GET /v1/course/{courseId}/download/{mediaId}", + app.mediaDownloadHandler, + ) + + // Submission operations + router.HandleFunc( + "POST /v1/course/assignment/{assignmentId}/submission/create", + app.submissionCreateHandler, + ) + router.HandleFunc( + "POST /v1/course/assignment/submission/{id}/update", + app.submissionUpdateHandler, + ) + router.HandleFunc( + "DELETE /v1/course/assignment/submission/{id}/delete", + app.submissionDeleteHandler, + ) + // Read submission from teacher view + router.HandleFunc( + "GET /v1/course/{courseId}/assignment/{assignmentId}/submission/{userId}/read", + app.teachersubmissionReadHandler, + ) + // Read submission from student view + router.HandleFunc( + "POST /v1/course/{courseId}/assignment/{assignmentId}/submission/read", + app.studentsubmissionReadHandler, + ) + + // Image operations + // router.HandlerFunc("POST /v1/course/image", app.courseImageHandler) + + // Offline grading operations + // Subtle difference, one is a GET, one is a POST. The POST expects + // data to be sent along with request. The GET just sends back data. + // The system does not need to know the ID of the course or the ID + // of the assignment, because this should be inside the sheet + // of the Excel document, under columns G2 and H2. + router.HandleFunc( + "GET /v1/course/{id}/assignment/{post}/offline", + app.sendOfflineTemplate, + ) + router.HandleFunc( + "POST /v1/course/{id}/assignment/{post}/offline", + app.receiveOfflineGrades) + router.HandleFunc( + "POST /v1/course/assignment/submission/{id}/upload", + app.submissionMediaUploadHandler, + ) return router } diff --git a/backend/cmd/api/server.go b/backend/cmd/api/server.go new file mode 100644 index 0000000..357b61f --- /dev/null +++ b/backend/cmd/api/server.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "time" +) + +// server creates a new server from the application's configuration parameters +// and middleware. +func (app *application) server() error { + // handler is the serve mux, wrapped with appropriate middleware. + //var handler http.Handler = app.recoverPanic( + // app.enableCORS( + // app.rateLimit( + // app. + // routes(), + // ), + // ), + //) + var handler http.Handler = app.enableCORS( + app.routes(), + ) + + //handler = app.routes() + // handler = app.enableCORS(app.rateLimit(app.routes())) + handler = app.enableCORS(app.routes()) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", app.config.port), + Handler: handler, + IdleTimeout: time.Minute, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + app.logger.Printf( + "starting server on %s:%s", app.config.db.Host, + strconv.Itoa(app.config.port), + ) + + return srv.ListenAndServe() +} diff --git a/backend/go.mod b/backend/go.mod index 3aec159..2c9e309 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,4 +2,23 @@ module github.com/n30w/Darkspace go 1.22.0 -require github.com/lib/pq v1.10.9 +require ( + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/lib/pq v1.10.9 + github.com/tealeg/xlsx v1.0.5 + github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce + github.com/xuri/excelize/v2 v2.8.1 + golang.org/x/time v0.5.0 +) + +require ( + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/richardlehane/mscfb v1.0.4 // indirect + github.com/richardlehane/msoleps v1.0.3 // indirect + github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect + github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum index aeddeae..8da7471 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,2 +1,34 @@ +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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= +github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= +github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= +github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/tealeg/xlsx v1.0.5/go.mod h1:btRS8dz54TDnvKNosuAqxrM1QgN1udgk9O34bDCnORM= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0= +github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ= +github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4= +github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/dal/errors.go b/backend/internal/dal/errors.go new file mode 100644 index 0000000..4399cd9 --- /dev/null +++ b/backend/internal/dal/errors.go @@ -0,0 +1,10 @@ +package dal + +import ( + "errors" +) + +var ( + ERR_RECORD_NOT_FOUND = errors.New("record not found") + ERR_INVALID_BY = errors.New("invalid get type received") +) diff --git a/backend/internal/dal/files.go b/backend/internal/dal/files.go new file mode 100644 index 0000000..25e831d --- /dev/null +++ b/backend/internal/dal/files.go @@ -0,0 +1,234 @@ +// files.go contains any data access layer representations that access +// data from specific file types, such as XLSX or CSV. + +package dal + +import ( + "encoding/csv" + "os" + "path" + + "github.com/n30w/Darkspace/internal/models" + "github.com/xuri/excelize/v2" +) + +const excelTemplateName = "grade-offline-template.xlsx" +const excelTemplateSheetName = "submissions" + +type ExcelStore struct { + excelTemplatePath, excelTemplateSheetName, excelTemplateName string +} + +// NewExcelStore returns an Excel store. it accepts a template +// path, which is the path of the template directory using +// the volume's directory. +func NewExcelStore(templatePath string) *ExcelStore { + e := &ExcelStore{ + excelTemplateSheetName: excelTemplateSheetName, + excelTemplateName: excelTemplateName, + } + + e.excelTemplatePath = path.Join(templatePath, e.excelTemplateName) + + return e +} + +// Open opens an Excel file at a specified path. Uses variadic +// parameters to accept an optional value. If the optional value +// is not set, uses the struct default templatePath. +func (es *ExcelStore) Open(path ...string) (*excelize.File, error) { + var p string + if len(path) >= 1 { + p = path[0] + } else { + p = es.excelTemplatePath + } + + f, err := excelize.OpenFile(p) + if err != nil { + return nil, err + } + + return f, nil +} + +// Get retrieves all the data in a file. It takes optional +// arguments. It is a slice, where index 0 is the path and +// index 1 is the sheet name. Defaults to struct initials +// if left blank. +func (es *ExcelStore) Get(path ...string) ( + [][]string, error, +) { + var p, n string + p = es.excelTemplatePath + n = es.excelTemplateSheetName + + if len(path) == 1 { + p = path[0] + } else if len(path) > 1 { + p = path[0] + n = path[1] + } + + // Open the file + f, err := es.Open(p) + if err != nil { + return nil, err + } + + f.Close() + + // Get all the rows in a sheet. + rows, err := f.GetRows(n) + if err != nil { + return nil, err + } + + return rows, nil +} + +// Save saves the Excel file to a place on disk, given a path. +func (es *ExcelStore) Save(file *excelize.File, to string) (string, error) { + err := file.SaveAs(to) + if err != nil { + return "", err + } + + return to, nil +} + +// AddRow adds a row to an Excel sheet. It takes a row and a start. +// Start is the starting cell from which to start adding cell values +// horizontally across columns. +func (es *ExcelStore) AddRow( + f *excelize.File, row *[]interface{}, + start string, +) error { + err := f.SetSheetRow(es.excelTemplateSheetName, start, row) + if err != nil { + return err + } + + return nil +} + +// ========================================================================== // +// CSV defines access operations for accessing data from a CSV file. +// This exists because we currently do not have a functioning database just yet. +// General overview of CSV handling in Go: +// https://earthly.dev/blog/golang-csv-files/ +// ========================================================================== // + +type CSVStore struct { + path string +} + +func NewCSVStore(p string) *CSVStore { + return &CSVStore{path: p} +} + +// readCSV reads a CSV file at the specified path. +// It returns a multidimensional array of strings. +func (cs *CSVStore) readCSV() ([][]string, error) { + f, err := os.Open(cs.path) + if err != nil { + return nil, err + } + + defer f.Close() + + r := csv.NewReader(f) + data, err := r.ReadAll() + if err != nil { + return nil, err + } + + return data, nil +} + +// writeCSV creates a new CSV file. +// This can be used in tandem with readCSV to read a CSV, +// delete a row from the slices, +// then write the new slices to a new CSV file that overwrites the original. +// Here is a helpful article on the writing to a CSV pattern: https +// ://gosamples.dev/write-csv/ +func (cs *CSVStore) writeCSV(data [][]string) error { + f, err := os.Create(cs.path) + if err != nil { + return err + } + + defer f.Close() + + writer := csv.NewWriter(f) + + defer writer.Flush() + + err = writer.WriteAll(data) + if err != nil { + return err + } + + return nil +} + +// updateCSV appends a line to a CSV file. +func (cs *CSVStore) updateCSV(row []string) error { + + f, err := os.OpenFile(cs.path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + + defer f.Close() + + writer := csv.NewWriter(f) + defer writer.Flush() + + err = writer.Write(row) + if err != nil { + return err + } + + return nil +} + +func (cs *CSVStore) InsertCourse(c *models.Course) error { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetCourseByName(name string) (*models.Course, error) { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetCourseByID(id string) (*models.Course, error) { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetRoster(id string) ([]models.User, error) { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) InsertUser(u *models.User) error { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetUserByID(id string) (*models.User, error) { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetUserByEmail(email string) (*models.User, error) { + //TODO implement me + panic("implement me") +} + +func (cs *CSVStore) GetByUsername(username string) (*models.User, error) { + //TODO implement me + panic("implement me") +} diff --git a/backend/internal/dal/files_test.go b/backend/internal/dal/files_test.go new file mode 100644 index 0000000..ffbddae --- /dev/null +++ b/backend/internal/dal/files_test.go @@ -0,0 +1,138 @@ +package dal + +import ( + "fmt" + "testing" +) + +const ( + excelStorePath = "../../resources/" + excelOutputPath = "../../resources/test/" + excelFileName = "grade-offline-template.xlsx" +) + +// defaultRow is the very top row of the Excel template file. +var defaultRow = []string{"Name", "Net ID", "Grade", "Feedback", + "Submission ID", "", "Course ID", "Assignment ID"} +var templatePath = excelStorePath + excelFileName + +func TestExcelStore_Open(t *testing.T) { + es := NewExcelStore() + + f, err := es.Open(templatePath) + if err != nil { + t.Errorf("%+v", err) + } + + defer f.Close() +} + +func TestExcelStore_Get(t *testing.T) { + es := NewExcelStore() + + want := [][]string{ + defaultRow, + } + + got, err := es.Get(excelStorePath + excelFileName) + if err != nil { + t.Errorf("%+v", err) + } + + err = multiDimComp(got, want) + if err != nil { + t.Errorf("%+v", err) + } +} + +func TestExcelStore_Save(t *testing.T) { + es := NewExcelStore() + + f, err := es.Open(templatePath) + if err != nil { + t.Errorf("%+v", err) + } + + fileName := "TestExcelStore_Save.xlsx" + + want := excelOutputPath + fileName + got, err := es.Save(f, want) + if err != nil { + t.Errorf("%+v", err) + } + + if want != got { + t.Errorf("got %s, want %s", got, want) + } +} + +func TestExcelStore_AddRow(t *testing.T) { + es := NewExcelStore() + + var got, want [][]string + + fileName := "TestExcelStore_AddRow.xlsx" + row := []interface{}{"Joe Mama", "jm123", 86.3, "Well done.", + "018f66c3-265d-7f2b-9b45-2d9606ad1d93"} + dataSerial := []string{"Joe Mama", "jm123", "86.3", "Well done.", "018f66c3-265d-7f2b-9b45-2d9606ad1d93"} + + want = [][]string{ + defaultRow, + dataSerial, + } + + // Access and open the template. + f, err := es.Open(templatePath) + if err != nil { + t.Errorf("%+v", err) + } + + defer f.Close() + + // Add new rows to the template. + err = es.AddRow(f, &row, "A2") + if err != nil { + t.Errorf("%+v", err) + } + + // Save the changes to the template at a new destination. + newSavePath := excelOutputPath + fileName + + p, err := es.Save(f, newSavePath) + if err != nil { + t.Errorf("%+v", err) + } + + // Read the changes back. + got, err = es.Get(p) + if err != nil { + t.Errorf("%+v", err) + } + + err = multiDimComp(got, want) + if err != nil { + t.Errorf("%+v", err) + } +} + +// multiDimComp compares two multidimensional arrays, checking +// their length and each of their values to each other. +func multiDimComp(got, want [][]string) error { + if len(got) != len(want) { + return fmt.Errorf("length got %d, want %d", len(got), len(want)) + } + + if len(got[0]) != len(want[0]) { + return fmt.Errorf("length got %d, want %d", len(got[0]), len(want[0])) + } + + for i := range want { + for j := range want[i] { + if got[i][j] != want[i][j] { + return fmt.Errorf("got %s, want %s", got[i][j], want[i][j]) + } + } + } + + return nil +} diff --git a/backend/internal/dal/setup.go b/backend/internal/dal/setup.go new file mode 100644 index 0000000..b00119e --- /dev/null +++ b/backend/internal/dal/setup.go @@ -0,0 +1,67 @@ +package dal + +import ( + "fmt" + "log" + "os" + + "github.com/joho/godotenv" +) + +// Set up a connection to a database. + +func NewDBConfig() DBConfig { + return DBConfig{ + Driver: "postgres", + MaxOpenConns: 25, + MaxIdleConns: 25, + MaxIdleTime: "15m", + } +} + +type DBConfig struct { + // Database driver and DataSourceName + Driver string + Dsn string + + // Database parameters, similar to .env file variables. + Name string + Username string + Password string + Host string + Port string + SslMode string + + MaxOpenConns int + MaxIdleConns int + MaxIdleTime string +} + +func (d DBConfig) SetFromEnv() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + d.Dsn = os.Getenv("DB_DSN") + d.Name = os.Getenv("DB_NAME") + d.Username = os.Getenv("DB_USERNAME") + d.Password = os.Getenv("DB_PASSWORD") + d.Host = os.Getenv("DB_HOST") + d.Port = os.Getenv("DB_PORT") + d.SslMode = os.Getenv("DB_SSL_MODE") +} + +// CreateDataSourceName creates the dataSourceName parameter of the +// sql.Open function. +func (d DBConfig) CreateDataSourceName() string { + return fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", + d.Host, + d.Port, + d.Username, + d.Password, + d.Name, + d.SslMode, + ) +} diff --git a/backend/internal/dal/storage.go b/backend/internal/dal/storage.go new file mode 100644 index 0000000..2edc522 --- /dev/null +++ b/backend/internal/dal/storage.go @@ -0,0 +1,69 @@ +// storage.go contains any data access layer representations that access +// any type of storage, such as a directory, a volume, or a remote object +// storage service such as Amazon S3. + +package dal + +import ( + "io" + "os" + "path" +) + +const darkspaceDirectory = "darkspace_volume" +const defaultsDirectory = "defaults" +const templateDirectory = "templates" + +type volume struct { + path string // path is the general URI path to the volume. + defaults string // defaults is where default resources are stored. + templates string // templates is where templates are stored. +} + +// String prints out the volume's set path. +func (v volume) String() string { + return v.path +} + +// Template returns the path of the templates. +func (v volume) Template() string { + return path.Join(v.templates) +} + +type LocalVolume struct { + volume +} + +func NewLocalVolume(p string) *LocalVolume { + v := &LocalVolume{ + volume: volume{ + path: path.Join(p, darkspaceDirectory), + }, + } + + v.defaults = path.Join(v.path, defaultsDirectory) + v.templates = path.Join(v.path, templateDirectory) + + return v +} + +// CreateFile makes a new file and returns it. Does not automatically close! +func (lv *LocalVolume) CreateFile(name string) (*os.File, string, error) { + p := path.Join(lv.path, name) + + f, err := os.Create(p) + if err != nil { + return nil, "", err + } + + return f, p, nil +} + +func (lv *LocalVolume) CopyFile(f1 io.Writer, f2 io.Reader) error { + _, err := io.Copy(f1, f2) + if err != nil { + return err + } + + return nil +} diff --git a/backend/internal/dal/storage_test.go b/backend/internal/dal/storage_test.go new file mode 100644 index 0000000..94261bd --- /dev/null +++ b/backend/internal/dal/storage_test.go @@ -0,0 +1,40 @@ +package dal + +import ( + "reflect" + "testing" +) + +func TestNewLocalVolume(t *testing.T) { + type args struct { + p string + } + tests := []struct { + name string + args args + want *LocalVolume + }{ + { + name: "Desktop", + args: args{p: "/Users/neo/Desktop"}, + want: &LocalVolume{ + volume{ + path: "/Users/neo/Desktop/darkspace_volume", + defaults: "/Users/neo/Desktop/darkspace_volume/defaults", + }, + }, + }, + } + for _, tt := range tests { + t.Run( + tt.name, func(t *testing.T) { + if got := NewLocalVolume(tt.args.p); !reflect.DeepEqual( + got, + tt.want, + ) { + t.Errorf("NewLocalVolume() = %v, want %v", got, tt.want) + } + }, + ) + } +} diff --git a/backend/internal/dal/store.go b/backend/internal/dal/store.go new file mode 100644 index 0000000..610d1ca --- /dev/null +++ b/backend/internal/dal/store.go @@ -0,0 +1,1434 @@ +package dal + +import ( + "context" + "database/sql" + "errors" + "fmt" + "mime/multipart" + "time" + + "github.com/n30w/Darkspace/internal/models" +) + +// Credential interface implementations. These implementations may seem +// somewhat redundant, but they are helpful, because it lets us test and +// validate the input once more to verify data integrity across boundaries. + +type username string +type password string +type email string +type Membership int +type ID string + +func (i ID) String() string { return string(i) } +func (i ID) Valid() error { return nil } + +func (u username) String() string { return string(u) } +func (u username) Valid() error { return nil } + +func (p password) String() string { return string(p) } +func (p password) Valid() error { return nil } + +func (e email) String() string { return string(e) } +func (e email) Valid() error { return nil } + +func (m Membership) String() string { return fmt.Sprintf("%d", m) } +func (m Membership) Valid() error { return nil } + +// Store implements interfaces found in respective domain packages. +type Store struct { + db *sql.DB +} + +var err error + +func NewStore(db *sql.DB) *Store { + return &Store{ + db: db, + } +} + +func (s *Store) InsertMediaReference(media *models.Media) error { + return nil +} + +func (s *Store) UploadMedia( + file multipart.File, + submission *models.Submission, +) { + //TODO implement me + panic("implement me") +} + +func (s *Store) GetSubmissionMedia(submission *models.Submission) (*models.Submission, error) { + query := `SELECT media_id FROM submission_media WHERE submission_id=$1` + rows, err := s.db.Query(query, submission.ID) + if err != nil { + return nil, err + } + defer rows.Close() + // List of media + for rows.Next() { + var mediaid string + err := rows.Scan( + &mediaid, + ) + if err != nil { + return nil, fmt.Errorf("error scanning row: %v", err) + } + submission.Media = append(submission.Media, mediaid) + } + fmt.Printf("Gettin") + return submission, nil +} + +func (s *Store) GetSubmissionById(submissionId string) ( + *models.Submission, + error, +) { + sub := models.NewSubmission() + fmt.Printf("getting submission by id %s \n", submissionId) + query := `SELECT id, submission_time, on_time, grade, feedback, user_id +FROM submissions WHERE id=$1` + + row := s.db.QueryRow(query, submissionId) + + err = row.Scan( + &sub.ID, &sub.SubmissionTime, &sub.OnTime, &sub.Grade, + &sub.Feedback, &sub.User.ID, + ) + if err != nil { + return nil, err + } + return sub, nil +} + +func (s *Store) GetSubmissionIdByUserAndAssignment(userId string, assignmentId string) (string, error) { + // Retrieve list of submissions by user + query := `SELECT submission_id FROM user_submissions WHERE user_net_id=$1` + + rows, err := s.db.Query(query, userId) + if err != nil { + return "", err + } + defer rows.Close() + // List of submissions from user + var submissions []string + for rows.Next() { + var submission string + err := rows.Scan( + &submission, + ) + if err != nil { + return "", fmt.Errorf("error scanning row: %v", err) + } + submissions = append(submissions, submission) + } + fmt.Printf("Submission ids related to use: %s\n", submissions) + fmt.Printf("Assignment id:%s\n", assignmentId) + + if err = rows.Err(); err != nil { + return "", fmt.Errorf("error iterating rows: %v", err) + } + var submissionid string + for _, id := range submissions { + query = `SELECT submission_id FROM assignment_submissions WHERE assignment_id=$1 AND submission_id=$2` + row := s.db.QueryRow(query, assignmentId, id) + err = row.Scan(&submissionid) + if err != nil { + switch err { + case sql.ErrNoRows: + continue + default: + return "", err + } + } + } + return submissionid, nil +} + +// GetSubmissions queries a junction table to retrieve all related +// submissions for an assignment. +func (s *Store) GetSubmissions(assignmentId string) ( + []*models.Submission, + error, +) { + var submissions []*models.Submission + query := ` + SELECT s.id, s.grade, s.feedback, u.full_name, u.net_id + FROM submissions s + JOIN assignment_submissions a ON s.id = a.submission_id + JOIN users u ON s.user_id = u.net_id + WHERE a.assignment_id = $1 + ` + + rows, err := s.db.Query(query, assignmentId) + if err != nil { + return nil, err + } + + defer rows.Close() + + for rows.Next() { + sub := models.NewSubmission() + err := rows.Scan( + &sub.ID, + &sub.Grade, + &sub.Feedback, + &sub.User.FullName, + &sub.User.ID, + ) + if err != nil { + return nil, fmt.Errorf("error scanning row: %v", err) + } + submissions = append(submissions, sub) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %v", err) + } + + return submissions, nil +} + +// UpdateSubmission returns the submission model that was input. +func (s *Store) UpdateSubmission(submission *models.Submission) error { + // Change the submission data in the database using the submission ID. + query := `UPDATE submissions SET grade = $1, +feedback = $2 WHERE id = $3 AND user_id = $4` + _, err := s.db.Exec( + query, submission.Grade, submission.Feedback, submission.ID, + submission.User.ID, + ) + if err != nil { + return err + } + + return nil +} + +func (s *Store) ChangeAssignment( + assignment *models.Assignment, + updatedfield string, + action string, +) (*models.Assignment, error) { + //TODO implement me + panic("implement me") +} + +// InsertUser inserts into the database using a user model. +func (s *Store) InsertUser(u *models.User) error { + id := 0 + stmt, err := s.db.Prepare( + ` + INSERT INTO users (net_id, created_at, updated_at, + username, password, email, membership, full_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, + ) + if err != nil { + return err + } + defer stmt.Close() + row := stmt.QueryRow( + u.ID, + u.CreatedAt, + u.UpdatedAt, + u.Username, + u.Password, + u.Email, + u.Membership, + u.FullName, + ) + if err := row.Scan(&id); err != nil { + return err + } + return nil +} + +// GetUserByID retrieves a user by their Net ID. It returns a +// struct with populated user information. +func (s *Store) GetUserByID(u *models.User) (*models.User, error) { + // First retrieve the user using their Net ID. + var ( + p, e string + m int + ) + + query := `SELECT net_id, full_name, password, email, membership FROM users WHERE net_id = $1` + + row := s.db.QueryRow(query, u.ID) + if err := row.Scan(&u.ID, &u.FullName, &p, &e, &m); err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ERR_RECORD_NOT_FOUND + default: + return nil, err + } + } + + u.Password = password(p) + u.Email = email(e) + u.Membership = Membership(m) + + // Now get their courses. + + var courses []string + + query = `SELECT uc.course_id FROM users u JOIN user_courses uc ON u. +net_id = uc.user_net_id WHERE u.net_id = $1` + + rows, err := s.db.Query(query, u.ID) + if err != nil { + return nil, err + } + + for rows.Next() { + var courseID string + + err := rows.Scan(&courseID) + if err != nil { + return nil, err + } + courses = append(courses, courseID) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + u.Courses = courses + + return u, nil +} + +// GetUserByEmail retrieves a user using a credential, returning +// a user model and error. +func (s *Store) GetUserByEmail(c models.Credential) (*models.User, error) { + u := &models.User{} + var e string + var f string + + query := `SELECT net_id, email, full_name FROM users WHERE email = $1` + row := s.db.QueryRow(query, c.String()) + if err := row.Scan(&u.ID, &e, &f); err != nil { + return nil, err + } + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ERR_RECORD_NOT_FOUND + default: + return nil, err + } + } + + u.Email = email(e) + u.FullName = f + + return u, nil +} + +func (s *Store) DeleteUserByNetID(netId string) (int64, error) { + query := `DELETE FROM users WHERE net_id = $1` + var result sql.Result + var err error + + result, err = s.db.Exec(query, netId) + + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return 0, ERR_RECORD_NOT_FOUND + default: + return 0, err + } + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, err + } + + return rows, nil +} + +func (s *Store) DeleteCourseByID(id string) error { + query := `DELETE FROM courses WHERE id = $1` + var err error + + _, err = s.db.Exec(query, id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ERR_RECORD_NOT_FOUND + default: + return err + } + } + + return nil +} + +func (s *Store) DeleteCourseByTitle(title string) (int64, error) { + query := `DELETE FROM courses WHERE title = $1` + var result sql.Result + var err error + + result, err = s.db.Exec(query, title) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return 0, ERR_RECORD_NOT_FOUND + default: + return 0, err + } + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, err + } + + return rows, nil +} + +func (s *Store) DeleteMediaByID(id string) (int64, error) { + query := `DELETE FROM media WHERE id = $1` + var result sql.Result + var err error + + result, err = s.db.Exec(query, id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return 0, ERR_RECORD_NOT_FOUND + default: + return 0, err + } + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, err + } + + return rows, nil +} + +func (s *Store) DeleteAssignmentByID(id string) error { + query := `DELETE FROM assignments WHERE id = $1` + var result sql.Result + var err error + + result, err = s.db.Exec(query, id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ERR_RECORD_NOT_FOUND + default: + return err + } + } + + _, err = result.RowsAffected() + if err != nil { + return err + } + + return nil +} + +func (s *Store) DeleteSubmissionByID(id string) error { + query := `DELETE FROM submissions WHERE id = $1` + var err error + + _, err = s.db.Exec(query, id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ERR_RECORD_NOT_FOUND + default: + return err + } + } + + return nil +} + +func (s *Store) DeleteCourseFromUser( + u *models.User, + courseid string, +) error { + var indexToRemove = -1 + for i, id := range u.Courses { + if id == courseid { + indexToRemove = i + break + } + } + + if indexToRemove == -1 { + return errors.New("course not found in user's list") + } + + u.Courses = append( + u.Courses[:indexToRemove], + u.Courses[indexToRemove+1:]..., + ) + + return nil +} + +func (s *Store) GetUserCourses(u *models.User) ([]models.Course, error) { + courses := make([]models.Course, 0) + for _, courseId := range u.Courses { + // bannerId may be null, so use NullString to check and use + // default value. + var bannerId sql.NullString + var course models.Course + + query := `SELECT id, title, banner_id FROM courses WHERE id = $1;` + row := s.db.QueryRow(query, courseId) + + err = row.Scan(&course.ID, &course.Title, &bannerId) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ERR_RECORD_NOT_FOUND + default: + return nil, err + } + } + + if bannerId.Valid { + course.Banner = bannerId.String + } else { + course.Banner = models.DefaultImageId + } + + query = `SELECT * FROM course_teachers WHERE course_id=$1` + + // Course ID variable for scanning return. + var ci string + + rows, err := s.db.Query(query, courseId) + if err != nil { + return nil, err + } + defer rows.Close() + + var teacherIDs []string + + for rows.Next() { + var teacherID string + if err := rows.Scan(&teacherID, &ci); err != nil { + return nil, err + } + teacherIDs = append(teacherIDs, teacherID) + } + if err := rows.Err(); err != nil { + return nil, err + } + + course.Teachers = teacherIDs + + courses = append(courses, course) + } + + return courses, nil +} + +// func (s *Store) GetCourseProfessors(u *models.User) ([]models.User, error) { +// professors := make([]models.User, 0) +// query := ` +// SELECT c.id, c.title, c.description, c.created_at, c.updated_at +// FROM users u +// JOIN user_courses uc ON u.net_id = uc.user_net_id +// JOIN courses c ON uc.course_id = c.id +// WHERE u.net_id = $1` + +// rows, err := s.db.Query(query, u.ID) +// if err != nil { +// return nil, err +// } + +// for rows.Next() { +// p := models.Course{} +// if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.CreatedAt); err != nil { +// switch { +// case errors.Is(err, sql.ErrNoRows): +// return nil, ERR_RECORD_NOT_FOUND +// default: +// return nil, err +// } +// } +// courses = append(courses, c) +// } + +// } +func (s *Store) InsertBanner(courseid string, bannerurl string) ( + string, + error, +) { + return "", nil +} + +// InsertCourse inserts a course into the database based on a model, +// then returns a string value that is the UUID. +func (s *Store) InsertCourse(c *models.Course) (string, error) { + query := `INSERT INTO courses (title, description, created_at, updated_at + ) VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` + var err error + var id string + + err = s.db.QueryRow(query, c.Title, c.Description).Scan(&id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return id, ERR_RECORD_NOT_FOUND + default: + return "", err + } + } + return id, nil +} + +func (s *Store) InsertIntoUserCourses(c *models.Course, userid string) error { + query := `INSERT INTO user_courses (user_net_id, course_id) VALUES ($1, $2);` + _, err = s.db.Query(query, userid, c.ID) + if err != nil { + return err + } + return nil +} + +func (s *Store) InsertTeacherToCourse(c *models.Course, t string) error { + var id string + // query := `INSERT INTO user_course (user_net_id, course_id) VALUES ($1, $2) RETURNING id` + query := `INSERT INTO courses (title, description, created_at, updated_at + ) VALUES ($1, $2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id` + // args := []interface{}{t, c.ID} + // err := s.db.QueryRow(query, args).Scan(&id) + err := s.db.QueryRow(query, c.Title, c.Description).Scan(&id) + if err != nil { + return err + } + return nil +} + +func (s *Store) CheckCourseProfessorDuplicate( + courseName string, + teacherid string, +) ( + bool, + error, +) { + var n int + query := `SELECT COUNT(*) AS course_count + FROM user_courses uc + JOIN courses c ON uc.course_id = c.id + WHERE uc.user_net_id = $1 + AND c.title = $2;` + row := s.db.QueryRow(query, teacherid, courseName) + if err := row.Scan(&n); err != nil { + return false, err + } + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return false, ERR_RECORD_NOT_FOUND + default: + return false, err + } + } + if n > 0 { + return true, nil + } else { + return false, nil + } + +} + +func (s *Store) GetMessagesByCourse(courseid string) ([]string, error) { + query := `SELECT message_id FROM course_messages WHERE course_id = $1` + rows, err := s.db.Query(query, courseid) + if err != nil { + return nil, err + } + defer rows.Close() + + var messageIds []string + + for rows.Next() { + var messageId string + if err := rows.Scan(&messageId); err != nil { + return nil, err + } + messageIds = append(messageIds, messageId) + } + if err := rows.Err(); err != nil { + return nil, err + } + return messageIds, nil +} + +func (s *Store) GetCourseByID(courseid string) ( + *models.Course, + error, +) { + c := &models.Course{} + c.ID = courseid + var bannerId sql.NullString + + query := `SELECT title, description, created_at, banner_id FROM courses WHERE id=$1` + rows, err := s.db.Query(query, courseid) + + if err != nil { + return nil, err + } + + for rows.Next() { + if err := rows.Scan( + &c.Title, + &c.Description, + &c.CreatedAt, + &bannerId, + ); err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, ERR_RECORD_NOT_FOUND + default: + return nil, err + } + } + } + + if bannerId.Valid { + c.Banner = bannerId.String + } else { + c.Banner = models.DefaultImageId + } + + err = rows.Err() + if err != nil { + return nil, err + } + return c, nil +} + +func (s *Store) GetRoster(courseid string) ( + []models.User, + error, +) { + var roster []models.User + var e string + + query := `SELECT u.net_id, u.email, u.full_name FROM users u + JOIN course_roster cr ON u.net_id = cr.student_id + WHERE cr.course_id = $1` + + rows, err := s.db.Query(query, courseid) + if err != nil { + return nil, err + } + + defer rows.Close() + + for rows.Next() { + var user models.User + if err := rows.Scan(&user.ID, &e, &user.FullName); err != nil { + return nil, err + } + user.Email = email(e) + roster = append(roster, user) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return roster, nil +} + +func (s *Store) DeleteCourse(c *models.Course) error { + query := ` + DELETE FROM courses + WHERE id = $1 + ` + + _, err := s.db.Exec(query, c.ID) + if err != nil { + return err + } + + return nil +} + +func (s *Store) InsertMessage( + m *models.Message, + courseid string, +) error { + query := `INSERT INTO messages (title, description, type, date) VALUES ($1, +$2, $3, $4) RETURNING id` + row := s.db.QueryRow( + query, + m.Title, + m.Description, + m.Type, + m.CreatedAt, + ) + + err := row.Scan(&m.ID) + if err != nil { + if err == sql.ErrNoRows { + return ERR_RECORD_NOT_FOUND + } + return err + } + courseQuery := `INSERT INTO course_messages (course_id, message_id) +VALUES ($1, $2)` + + _, err = s.db.Exec(courseQuery, courseid, m.Post.ID) + if err != nil { + return err + } + + return nil +} + +func (s *Store) GetMessageById(messageid string) ( + *models.Message, + error, +) { + message := &models.Message{} + + query := `SELECT id, title, description, type, +date FROM messages WHERE id = $1` + row := s.db.QueryRow(query, messageid) + + err := row.Scan( + &message.Post.ID, + &message.Title, + &message.Description, + &message.Type, + &message.Date, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + + return message, nil +} +func (s *Store) DeleteMessageByID(id string) error { + query := `DELETE FROM messages WHERE id = $1` + var err error + + _, err = s.db.Exec(query, id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + return ERR_RECORD_NOT_FOUND + default: + return err + } + } + + return nil +} + +func (s *Store) ChangeMessageTitle(m *models.Message) (*models.Message, error) { + query := `UPDATE messages SET title = $1 WHERE id = $2 RETURNING id, title, description, media, date, course, owner` + + row := s.db.QueryRow(query, m.Title, m.Post.ID) + + updatedMessage := &models.Message{} + err := row.Scan( + &updatedMessage.Post.ID, + &updatedMessage.Title, + &updatedMessage.Description, + &updatedMessage.Media, + &updatedMessage.Course, + &updatedMessage.Owner, + ) + if err != nil { + return nil, err + } + + return updatedMessage, nil +} + +func (s *Store) ChangeMessageBody(m *models.Message) (*models.Message, error) { + query := `UPDATE messages SET description = $1 WHERE id = $2 RETURNING id, title, description, media, date, course, owner` + + row := s.db.QueryRow(query, m.Description, m.Post.ID) + + updatedMessage := &models.Message{} + err := row.Scan( + &updatedMessage.Post.ID, + &updatedMessage.Title, + &updatedMessage.Description, + &updatedMessage.Media, + &updatedMessage.Course, + &updatedMessage.Owner, + ) + if err != nil { + return nil, err + } + + return updatedMessage, nil +} + +func (s *Store) GetAssignmentById(assignmentid string) ( + *models.Assignment, + error, +) { + assignment := models.NewAssignment() + + query := `SELECT id, title, description, due_date FROM assignments WHERE id = $1` + row := s.db.QueryRow(query, assignmentid) + + err := row.Scan( + &assignment.ID, + &assignment.Title, + &assignment.Description, + &assignment.DueDate, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + + return assignment, nil +} + +func (s *Store) InsertAssignment(a *models.Assignment) ( + *models.Assignment, + error, +) { + query := `INSERT INTO assignments (title, description, due_date) VALUES ($1, $2, $3) RETURNING id` + + row := s.db.QueryRow(query, a.Title, a.Description, a.DueDate) + if err != nil { + return nil, err + } + err = row.Scan(&a.ID) + if err != nil { + if err == sql.ErrNoRows { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + return a, err +} + +func (s *Store) InsertIntoCourseAssignments(a *models.Assignment) ( + *models.Assignment, + error, +) { + coursequery := `INSERT INTO course_assignments (course_id, assignment_id) VALUES ($1, $2)` + _, err = s.db.Exec(coursequery, a.Course, a.ID) + if err != nil { + return nil, err + } + return a, err +} + +func (s *Store) InsertAssignmentIntoUser(a *models.Assignment) ( + *models.Assignment, + error, +) { + userquery := `INSERT INTO user_assignments (user_net_id, assignment_id) VALUES ($1, $2)` + _, err = s.db.Exec(userquery, a.Owner, a.ID) + if err != nil { + return nil, err + } + return a, err +} + +func (s *Store) DeleteAssignment(a *models.Assignment) error { + query := `DELETE FROM assignments WHERE id = $1` + + _, err := s.db.Exec(query, a.ID) + if err != nil { + return err + } + + return nil +} +func (s *Store) GetAssignmentsByCourse(courseid string) ([]string, error) { + query := `SELECT assignment_id FROM course_assignments WHERE course_id = $1` + rows, err := s.db.Query(query, courseid) + if err != nil { + return nil, err + } + defer rows.Close() + + var assignmentIds []string + + for rows.Next() { + var assignmentId string + if err := rows.Scan(&assignmentId); err != nil { + return nil, err + } + assignmentIds = append(assignmentIds, assignmentId) + } + if err := rows.Err(); err != nil { + return nil, err + } + return assignmentIds, nil +} + +func (s *Store) ChangeAssignmentTitle( + assignment *models.Assignment, + title string, +) (*models.Assignment, error) { + query := `UPDATE assignments SET title = $1 WHERE id = $2 RETURNING id, title, description, due_date, course_id` + + row := s.db.QueryRow(query, title, assignment.ID) + + updatedAssignment := &models.Assignment{} + err := row.Scan( + &updatedAssignment.ID, + &updatedAssignment.Title, + &updatedAssignment.Description, + &updatedAssignment.DueDate, + &updatedAssignment.Course, + ) + if err != nil { + return nil, err + } + + return updatedAssignment, nil +} + +func (s *Store) ChangeAssignmentBody( + assignment *models.Assignment, + body string, +) (*models.Assignment, error) { + query := `UPDATE assignments SET description = $1 WHERE id = $2 RETURNING id, title, description, due_date, course_id` + + row := s.db.QueryRow(query, body, assignment.ID) + + updatedAssignment := &models.Assignment{} + err := row.Scan( + &updatedAssignment.ID, + &updatedAssignment.Title, + &updatedAssignment.Description, + &updatedAssignment.DueDate, + &updatedAssignment.Course, + ) + if err != nil { + return nil, err + } + + return updatedAssignment, nil +} + +// InsertToken inserts a created token for a user. +func (s *Store) InsertToken(t *models.Token) error { + query := `INSERT INTO tokens (hash, net_id, expiry, scope) VALUES ($1, $2, $3, $4)` + args := []any{t.Hash, t.NetID, t.Expiry, t.Scope} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := s.db.ExecContext(ctx, query, args...) + + return err +} + +func (s *Store) GetTokenFromNetId(t *models.Token) (*models.Token, error) { + query := `SELECT hash, expiry, scope FROM tokens WHERE net_id = $1` + + row := s.db.QueryRow(query, t.NetID) + + err := row.Scan( + &t.Hash, + &t.Expiry, + &t.Scope, + ) + if err != nil { + switch err { + case sql.ErrNoRows: + return nil, ERR_RECORD_NOT_FOUND + default: + return nil, err + } + } + + return t, nil +} + +// DeleteTokenFrom deletes a user's authentication Token using their +// Net ID. +func (s *Store) DeleteTokenFrom(netId, scope string) error { + query := `DELETE FROM tokens WHERE scope = $1 AND net_id = $2` + + args := []any{scope, netId} + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := s.db.ExecContext(ctx, query, args...) + return err +} + +// ################## +// JUNCTION METHODS +// ################## +// +// Junction methods function upon junction tables. They change +// the relationships between database objects. + +// AddTeacher adds a teacher to a specified course, using the teacher's +// userId. This method uses junction tables to assign relationships. +func (s *Store) AddTeacher(courseId string, userId string) error { + // Start a transaction + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("error starting transaction: %v", err) + } + + // Check if the course exists + var exists bool + err = tx.QueryRow( + "SELECT EXISTS(SELECT 1 FROM courses WHERE id = $1)", + courseId, + ).Scan(&exists) + + if err != nil { + tx.Rollback() + return fmt.Errorf("error checking course existence: %v", err) + } + + if !exists { + tx.Rollback() + return fmt.Errorf("course with ID %s does not exist", courseId) + } + + // Check if the teacher exists + err = tx.QueryRow( + "SELECT EXISTS(SELECT 1 FROM users WHERE net_id = $1)", + userId, + ).Scan(&exists) + + if err != nil { + tx.Rollback() + return fmt.Errorf("error checking teacher existence: %v", err) + } + + if !exists { + tx.Rollback() + return fmt.Errorf("teacher with ID %s does not exist", userId) + } + + // Insert the new relationship into the junction table + _, err = tx.Exec( + "INSERT INTO course_teachers (course_id, teacher_id) VALUES ($1, $2)", + courseId, + userId, + ) + + if err != nil { + tx.Rollback() + return fmt.Errorf("error inserting into course_teachers: %v", err) + } + + // Commit the transaction + if err = tx.Commit(); err != nil { + tx.Rollback() + return fmt.Errorf("error committing transaction: %v", err) + } + + return nil +} + +func (s *Store) ChangeAssignmentDueDate( + assignment *models.Assignment, + duedate time.Time, +) (*models.Assignment, error) { + return nil, nil +} + +func (s *Store) GetMediaReferenceById(media *models.Media) error { + return nil +} + +// AddStudent uses junction tables to insert a new student +// into a course. +func (s *Store) AddStudent(c *models.Course, userid string) ( + *models.Course, + error, +) { + query := `INSERT INTO course_roster (course_id, student_id) VALUES ($1, $2)` + + _, err := s.db.Exec(query, c.ID, userid) + if err != nil { + return nil, err + } + + return c, nil +} + +func (s *Store) RemoveStudent(c *models.Course, userid string) ( + *models.Course, + error, +) { + query := `DELETE FROM course_roster WHERE student_id=$1 AND course_id=$2` + + _, err := s.db.Exec(query, userid, c.ID) + if err != nil { + return nil, err + } + query = `DELETE FROM user_courses WHERE user_net_id=$1 AND course_id=$2` + + _, err = s.db.Exec(query, userid, c.ID) + if err != nil { + return nil, err + } + + return c, nil +} + +func (s *Store) InsertSubmission( + sub *models.Submission, +) ( + *models.Submission, + error, +) { + query := `INSERT INTO submissions (submission_time, on_time, grade, feedback) VALUES ($1, $2, $3, $4) RETURNING id` + + row := s.db.QueryRow( + query, + &sub.SubmissionTime, + &sub.OnTime, + &sub.Grade, + &sub.Feedback, + ) + err := row.Scan( + &sub.ID, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + return sub, nil +} + +func (s *Store) InsertSubmissionIntoAssignment(sub *models.Submission) (*models.Submission, error) { + query := `INSERT INTO assignment_submissions (assignment_id, submission_id) VALUES ($1, $2)` + + _, err := s.db.Exec(query, sub.AssignmentId, sub.ID) + if err != nil { + return nil, err + } + return sub, nil +} +func (s *Store) InsertSubmissionIntoUser(sub *models.Submission) (*models.Submission, error) { + query := `INSERT INTO user_submissions (user_net_id, submission_id) VALUES ($1, $2)` + + _, err := s.db.Exec(query, sub.User.ID, sub.ID) + if err != nil { + return nil, err + } + return sub, nil +} + +func (s *Store) GradeSubmission( + grade float64, + submission *models.Submission, +) error { + return nil +} + +func (s *Store) InsertSubmissionFeedback( + feedback string, + submission *models.Submission, +) error { + return nil +} + +func (s *Store) GetMembershipById(userid string) ( + *models.Credential, + error, +) { + u := &models.User{} + + var m int + + query := `SELECT id, membership FROM users WHERE net_id = $1` + row := s.db.QueryRow(query, userid) + + err := row.Scan( + &u.ID, + &m, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + u.Membership = Membership(m) + return &u.Membership, nil +} + +func (s *Store) GetNetIdFromHash(hash []byte) ( + string, + error, +) { + u := &models.User{} + query := `SELECT net_id FROM tokens WHERE hash = $1` + row := s.db.QueryRow(query, hash) + + err = row.Scan( + &u.ID, + ) + if err != nil { + if err == sql.ErrNoRows { + return "", ERR_RECORD_NOT_FOUND + } + return "", err + } + return u.ID, nil +} + +func (s *Store) GetNameById(userid string) ( + *models.Credential, + error, +) { + u := &models.User{} + + var m int + + query := `SELECT id, membership FROM users WHERE net_id = $1` + row := s.db.QueryRow(query, userid) + + err := row.Scan( + &u.ID, + &m, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + u.Membership = Membership(m) + return &u.Membership, nil +} + +func (s *Store) InsertMedia( + m *models.Media, +) ( + *models.Media, + error, +) { + query := `INSERT INTO media (type, path, created_at, updated_at) VALUES ($1, $2, $3, $4) RETURNING id` + + row := s.db.QueryRow( + query, + m.FileType, + m.FilePath, + m.CreatedAt, + m.UpdatedAt, + ) + err := row.Scan(&m.ID) + if err != nil { + return nil, err + } + + return m, nil +} + +func (s *Store) GetMediaById(mediaId string) ( + *models.Media, + error, +) { + media := &models.Media{} + + query := `SELECT id, type, path FROM media WHERE id = $1` + row := s.db.QueryRow(query, mediaId) + + err := row.Scan( + &media.ID, + &media.FileType, + &media.FilePath, + ) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ERR_RECORD_NOT_FOUND + } + return nil, err + } + + return media, nil +} + +func (s *Store) InsertMediaIntoCourse( + m *models.Media, +) error { + query := `INSERT INTO course_media (course_id, media_id, media_path) VALUES ($1, $2, $3)` + + _, err := s.db.Exec(query, m.AttributionsByType["course"], m.ID, m.FilePath) + if err != nil { + return err + } + return nil +} +func (s *Store) InsertMediaIntoCourseBanner( + m *models.Media, +) error { + + query := `UPDATE courses SET banner_id = $2 WHERE id = $1;` + _, err = s.db.Exec(query, m.AttributionsByType["course"], m.ID) + if err != nil { + return err + } + return nil +} + +func (s *Store) InsertMediaIntoAssignment( + m *models.Media, +) error { + query := `INSERT INTO assignment_media (assignment_id, media_id, media_path) VALUES ($1, $2, $3)` + + _, err := s.db.Exec( + query, + m.AttributionsByType["assignment"], + m.ID, + m.FilePath, + ) + if err != nil { + return err + } + + return nil +} + +func (s *Store) InsertMediaIntoSubmission( + m *models.Media, +) error { + query := `INSERT INTO submission_media (submission_id, media_id, media_path) VALUES ($1, $2, $3)` + + _, err := s.db.Exec( + query, + m.AttributionsByType["submission"], + m.ID, + m.FilePath, + ) + if err != nil { + return err + } + + return nil +} diff --git a/backend/internal/dal/store_test.go b/backend/internal/dal/store_test.go new file mode 100644 index 0000000..3050e95 --- /dev/null +++ b/backend/internal/dal/store_test.go @@ -0,0 +1,377 @@ +package dal + +import ( + "context" + "database/sql" + "log" + "os" + "testing" + "time" + + "github.com/joho/godotenv" + + _ "github.com/lib/pq" + "github.com/n30w/Darkspace/internal/models" +) + +// setupDatabaseTest creates a connection to an already running +// postgresql database, for running tests. +func setupDatabaseTest(t *testing.T) (*sql.DB, error) { + t.Helper() + var dbConf DBConfig + dbConf.Driver = "postgres" + dbConf.SetFromEnv() + + db, err := sql.Open(dbConf.Driver, dbConf.CreateDataSourceName()) + if err != nil { + return nil, err + } + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxOpenConns(25) + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxIdleConns(25) + + duration, err := time.ParseDuration("15m") + if err != nil { + return nil, err + } + + db.SetConnMaxIdleTime(duration) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.PingContext(ctx) + if err != nil { + return nil, err + } + + return db, nil +} + +func TestDB(t *testing.T) { + var dbConf DBConfig + dbConf.Driver = "postgres" + + rootEnvFile := "../../.env" + err := godotenv.Load(rootEnvFile) + if err != nil { + log.Fatal("Error loading .env file") + } + + dbConf.Dsn = os.Getenv("DB_DSN") + dbConf.Name = os.Getenv("DB_NAME") + dbConf.Username = os.Getenv("DB_USERNAME") + dbConf.Password = os.Getenv("DB_PASSWORD") + dbConf.Host = os.Getenv("DB_HOST") + dbConf.Port = os.Getenv("DB_PORT") + dbConf.SslMode = os.Getenv("DB_SSL_MODE") + + db, err := sql.Open(dbConf.Driver, dbConf.CreateDataSourceName()) + if err != nil { + t.Errorf("%v", err) + } + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxOpenConns(25) + + // Passing a value less than or equal to 0 means no limit. + db.SetMaxIdleConns(25) + + duration, err := time.ParseDuration("15m") + if err != nil { + t.Errorf("%v", err) + } + + db.SetConnMaxIdleTime(duration) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = db.PingContext(ctx) + if err != nil { + t.Errorf("%v", err) + } + + t.Cleanup( + func() { + db.Close() + }, + ) + + store := NewStore(db) + + ei := "abc123" + + // This should match the dev-init.sql file's first entry. + expectedUser := &models.User{ + Entity: models.Entity{ID: ei}, + Credentials: models.Credentials{ + Username: username("jcena"), + Password: password("password123"), + Email: email("abc123@nyu.edu"), + Membership: membership(0), + }, + FullName: "John Cena", + ProfilePicture: models.Media{}, + Bio: "Can you see me?", + } + + expectedCourse := &models.Course{ + Entity: models.Entity{ID: "c3b34a9f-8f59-4818-a684-9cda56f42d02"}, + Title: "Clown Foundations", + Description: "Learn how to be a clown", + Messages: [10]string{}, + Teachers: nil, + Roster: nil, + Assignments: nil, + Archived: false, + } + + // ################# + // REMOVAL TESTS + // ################# + + t.Run( + "delete user by id", func(t *testing.T) { + id := "ghi987" + n, err := store.DeleteUserByNetID(id) + if err != nil { + t.Errorf("%v", err) + } + + if n == 0 { + t.Errorf("no rows deleted") + } + }, + ) + + t.Run( + "delete course by title", func(t *testing.T) { + title := "Delete This Course" + n, err := store.DeleteCourseByTitle(title) + if err != nil { + t.Errorf("%v", err) + } + + if n == 0 { + t.Errorf("no rows deleted") + } + }, + ) + + // ################# + // RETRIEVAL TESTS + // ################# + + // t.Run( + // "get user by id", func(t *testing.T) { + // var ua, u *models.User + // ua, err = models.NewUser("abc123", models.Credentials{}, "John Cena") + // u, err = store.GetUserByID(ua) + // if err != nil { + // t.Errorf("%s", err) + // } + + // if u.ID != expectedUser.ID { + // t.Errorf("got %s, want %s", u.ID, expectedUser.ID) + // } + + // if u.Email.String() != expectedUser.Email.String() { + // t.Errorf("got %s, want %s", u.Email, expectedUser.Email) + // } + + // if u.FullName != expectedUser.FullName { + // t.Errorf("got %s, want %s", u.FullName, expectedUser.FullName) + // } + // }, + // ) + + // t.Run( + // "get user by id_2", func(t *testing.T) { + // var id ID = "abc123" + // u, err := store.GetUserById_2(id) + // if err != nil { + // t.Errorf("%v", err) + // } + + // if u.ID != expectedUser.ID { + // t.Errorf("got %s, want %s", u.ID, expectedUser.ID) + // } + + // if u.Email.String() != expectedUser.Email.String() { + // t.Errorf("got %s, want %s", u.Email, expectedUser.Email) + // } + + // if u.FullName != expectedUser.FullName { + // t.Errorf("got %s, want %s", u.FullName, expectedUser.FullName) + // } + // }, + // ) + + t.Run( + "get user by email", func(t *testing.T) { + var e email = "abc123@nyu.edu" + u, err := store.GetUserByEmail(e) + if err != nil { + t.Errorf("%v", err) + } + + if u.ID != expectedUser.ID { + t.Errorf("got %s, want %s", u.ID, expectedUser.ID) + } + + if u.Email.String() != expectedUser.Email.String() { + t.Errorf("got %s, want %s", u.Email, expectedUser.Email) + } + + if u.FullName != expectedUser.FullName { + t.Errorf("got %s, want %s", u.FullName, expectedUser.FullName) + } + }, + ) + + //t.Run( + // "get course by title", func(t *testing.T) { + // title := "Clown Foundations" + // c, err := store.GetCourseByName(title) + // if err != nil { + // t.Errorf("%v", err) + // } + // + // if c.ID != expectedCourse.ID { + // t.Errorf("got %s, want %s", c.ID, expectedCourse.ID) + // } + // + // if c.Description != expectedCourse.Description { + // t.Errorf( + // "got %s, want %s", + // c.Description, + // expectedCourse.Description, + // ) + // } + // }, + //) + + t.Run( + "get course by id", func(t *testing.T) { + id := "c3b34a9f-8f59-4818-a684-9cda56f42d02" + c, err := store.GetCourseByID(id) + if err != nil { + t.Errorf("%v", err) + } + + if c.Title != expectedCourse.Title { + t.Errorf("got %s, want %s", c.Title, expectedCourse.Title) + } + + if c.Description != expectedCourse.Description { + t.Errorf( + "got %s, want %s", + c.Description, + expectedCourse.Description, + ) + } + }, + ) + + // ################# + // INSERTION TESTS + // ################# + + t.Run( + "insert user", func(t *testing.T) { + var n username = "testuser" + var id string = "xyz123" + + cred := models.Credentials{ + Username: n, + Password: password("testpassword"), + Email: email("test@example.com"), + Membership: membership(0), + } + + t.Cleanup( + func() { + store.DeleteUserByNetID(id) + }, + ) + + u := &models.User{ + Entity: models.Entity{ + ID: id, + }, + Credentials: cred, + } + + err := store.InsertUser(u) + if err != nil { + t.Errorf("%v", err) + } + + _, err = store.GetUserByID(u) + if err != nil { + t.Errorf("%v", err) + } + }, + ) + + t.Run( + "insert course", func(t *testing.T) { + c := &models.Course{ + Entity: models.Entity{ID: "623208ea-7d83-4cf3-9f31" + + "-b21d0ce151ff"}, + Title: "Nonsense and BS: An Introduction", + Description: "Everything you need to know about getting your" + + " way without knowing anything. " + + "Pre-requisite to Intermediate Conning", + } + + id, err := store.InsertCourse(c) + if err != nil { + t.Errorf("%v", err) + } + + t.Cleanup( + func() { + store.DeleteCourseByID(id) + }, + ) + + _, err = store.GetCourseByID(c.ID) + if err != nil { + t.Errorf("%v", err) + } + }, + ) + + // ################ + // JUNCTION TESTS + // ################ + + t.Run( + "add teacher to course", func(t *testing.T) { + userId := "uvw321" + err := store.AddTeacher(expectedCourse.ID, userId) + if err != nil { + t.Errorf("%v", err) + } + + // Check for bidirectional representations. + + //t.Run( + // "teacher has course", func(t *testing.T) { + // + // }, + //) + // + //t.Run( + // "course has teacher", func(t *testing.T) { + // + // }, + //) + }, + ) +} diff --git a/backend/internal/dao/models.go b/backend/internal/dao/models.go deleted file mode 100644 index 652fde3..0000000 --- a/backend/internal/dao/models.go +++ /dev/null @@ -1,69 +0,0 @@ -package dao - -import ( - "database/sql" - "errors" - - "github.com/lib/pq" - models "github.com/n30w/Darkspace/internal/domain" -) - -var ( - ERR_RECORD_NOT_FOUND = errors.New("record not found") - ERR_INVALID_BY = errors.New("invalid get type received") -) - -type Models struct { - Course *CourseModel -} - -func NewModels(db *sql.DB) *Models { - return &Models{ - Course: &CourseModel{db}, - } -} - -type CourseModel struct{ db *sql.DB } - -func (m CourseModel) Insert(c *models.Course) error { - return nil -} - -func (m CourseModel) Get(by string) (*models.Course, error) { - - if by == "" { - return nil, ERR_INVALID_BY - } - - var course models.Course - - q := `` - - // args is the type of stuff we're scanning for. - args := []any{} - - err := m.db.QueryRow(q, args).Scan( - &course.Name, - &course.ID, - pq.Array(&course.Teachers), - ) - - if err != nil { - switch { - case errors.Is(err, sql.ErrNoRows): - return nil, ERR_RECORD_NOT_FOUND - default: - return nil, err - } - } - - return &course, nil -} - -func (m CourseModel) Update(c *models.Course) error { - return nil -} - -func (m CourseModel) Delete(ID string) error { - return nil -} diff --git a/backend/internal/dao/storage.go b/backend/internal/dao/storage.go deleted file mode 100644 index 1503f04..0000000 --- a/backend/internal/dao/storage.go +++ /dev/null @@ -1,3 +0,0 @@ -package dao - -// Amazon S3 Bucket diff --git a/backend/internal/domain/assignment.go b/backend/internal/domain/assignment.go new file mode 100644 index 0000000..8d5bd70 --- /dev/null +++ b/backend/internal/domain/assignment.go @@ -0,0 +1,134 @@ +package domain + +import ( + "fmt" + + // "github.com/google/uuid" + "github.com/n30w/Darkspace/internal/models" +) + +type AssignmentStore interface { + GetAssignmentById(assignmentid string) (*models.Assignment, error) + GetAssignmentsByCourse(courseid string) ([]string, error) + InsertIntoCourseAssignments(a *models.Assignment) ( + *models.Assignment, + error, + ) + InsertAssignmentIntoUser(a *models.Assignment) (*models.Assignment, error) + InsertAssignment(assignment *models.Assignment) (*models.Assignment, error) + DeleteAssignmentByID(assignmentid string) error + ChangeAssignment( + assignment *models.Assignment, + updatedfield string, + action string, + ) (*models.Assignment, error) +} + +type AssignmentService struct { + store AssignmentStore +} + +func NewAssignmentService(a AssignmentStore) *AssignmentService { return &AssignmentService{store: a} } + +// ReadAssignment uses an Assignment's ID to retrieve it from +// the database. Options can also be passed in that specify +// what types of data transformations can be done, for example +// changing the date to a readable format. +func (as *AssignmentService) ReadAssignment( + assignmentId string, + opts ...func(assignment *models.Assignment) error, +) ( + *models.Assignment, + error, +) { + assignment, err := as.store.GetAssignmentById(assignmentId) + if err != nil { + return nil, err + } + + if len(opts) > 0 { + for _, opt := range opts { + err := opt(assignment) + if err != nil { + return nil, fmt.Errorf("option transform error: ", err) + } + } + } + + return assignment, nil +} + +// RetrieveAssignments retrieves an assignment using a specific +// Course ID. It returns a slice of all the assignments in a course. +func (as *AssignmentService) RetrieveAssignments(courseid string) ( + []string, + error, +) { + assignmentIds, err := as.store.GetAssignmentsByCourse(courseid) + if err != nil { + return nil, err + } + return assignmentIds, nil +} + +func (as *AssignmentService) CreateAssignment(assignment *models.Assignment) ( + *models.Assignment, + error, +) { + assignment, err := as.store.InsertAssignment(assignment) + if err != nil { + return nil, err + } + + assignment, err = as.store.InsertIntoCourseAssignments(assignment) + if err != nil { + return nil, err + } + + assignment, err = as.store.InsertAssignmentIntoUser(assignment) + if err != nil { + return nil, err + } + + return assignment, nil +} + +func (as *AssignmentService) UpdateAssignment( + assignmentid string, + updatedfield interface{}, + action string, +) (*models.Assignment, error) { + + assignment, err := as.store.GetAssignmentById(assignmentid) + if err != nil { + return nil, err + } + + if action == "body" || action == "title" || action == "duedate" { + if _, ok := updatedfield.(string); !ok { + return nil, fmt.Errorf( + "updated field is not of type string, it is of type %T", + updatedfield, + ) + } + assignment, err := as.store.ChangeAssignment( + assignment, + updatedfield.(string), + action, + ) + if err != nil { + return nil, err + } + return assignment, nil + } else { + return nil, fmt.Errorf("%s is an invalid action", action) + } +} + +func (as *AssignmentService) DeleteAssignment(assignmentid string) error { + err := as.store.DeleteAssignmentByID(assignmentid) + if err != nil { + return err + } + return nil +} diff --git a/backend/internal/domain/authentication.go b/backend/internal/domain/authentication.go new file mode 100644 index 0000000..cf6b9f0 --- /dev/null +++ b/backend/internal/domain/authentication.go @@ -0,0 +1,56 @@ +package domain + +import ( + "time" + + "github.com/n30w/Darkspace/internal/models" +) + +type AuthenticationStore interface { + InsertToken(t *models.Token) error + DeleteTokenFrom(netId, scope string) error + GetNetIdFromHash(hash []byte) (string, error) + GetTokenFromNetId(t *models.Token) (*models.Token, error) +} + +type AuthenticationService struct{ store AuthenticationStore } + +func NewAuthenticationService(as AuthenticationStore) *AuthenticationService { + return &AuthenticationService{store: as} +} + +func (as *AuthenticationService) NewToken(netId string) (*models.Token, error) { + token, err := models.GenerateToken(netId, 24*time.Hour, "authentication") + if err != nil { + return nil, err + } + + err = as.store.InsertToken(token) + if err != nil { + return nil, err + } + + return token, nil +} + +func (as *AuthenticationService) RetrieveToken(netId string) (*models.Token, error) { + t := &models.Token{ + NetID: netId, + } + + token, err := as.store.GetTokenFromNetId(t) + if err != nil { + return nil, err + } + + return token, err +} + +func (as *AuthenticationService) GetNetIdFromToken(token string) (string, error) { + hash := models.GenerateTokenHash(token) + netid, err := as.store.GetNetIdFromHash(hash) + if err != nil { + return "", err + } + return netid, nil +} diff --git a/backend/internal/domain/course.go b/backend/internal/domain/course.go new file mode 100644 index 0000000..c00857e --- /dev/null +++ b/backend/internal/domain/course.go @@ -0,0 +1,118 @@ +package domain + +import ( + "fmt" + + "github.com/n30w/Darkspace/internal/models" +) + +type CourseStore interface { + InsertCourse(c *models.Course) (string, error) + GetCourseByID(courseid string) (*models.Course, error) + GetRoster(c string) ([]models.User, error) + DeleteCourseByID(courseid string) error + AddStudent(c *models.Course, userid string) (*models.Course, error) + RemoveStudent(c *models.Course, userid string) (*models.Course, error) + CheckCourseProfessorDuplicate(courseName string, teacherId string) (bool, error) + InsertIntoUserCourses(c *models.Course, userid string) error +} + +type CourseService struct { + store CourseStore +} + +func NewCourseService(c CourseStore) *CourseService { return &CourseService{store: c} } + +// CreateCourse creates a new course in the database, +// then assigns a UUID to it. This is not an idempotent method! +func (cs *CourseService) CreateCourse(c *models.Course, teacherid string) (*models.Course, error) { + // Check if course already exists. Can also try and do fuzzy name matching. + duplicate, err := cs.store.CheckCourseProfessorDuplicate(c.Title, teacherid) + if err != nil { + return nil, err + } + + if duplicate { + return nil, fmt.Errorf("course already exists") + } + + // c.ID = uuid.New().String() + + // Create the course. + id, err := cs.store.InsertCourse(c) + if err != nil { + return nil, err + } + c.ID = id + err = cs.store.InsertIntoUserCourses(c, teacherid) + if err != nil { + return nil, err + } + + return c, nil +} + +func (cs *CourseService) RetrieveCourse(courseid string) ( + *models.Course, + error, +) { + c, err := cs.store.GetCourseByID(courseid) + if err != nil { + return nil, err + } + + return c, nil +} + +func (cs *CourseService) RetrieveRoster(courseid string) ( + []models.User, + error, +) { + c, err := cs.store.GetRoster(courseid) + if err != nil { + return nil, err + } + return c, nil +} + +func (cs *CourseService) AddToRoster( + courseid string, + userid string, +) (*models.Course, error) { + c, err := cs.store.GetCourseByID(courseid) + if err != nil { + return nil, err + } + c, err = cs.store.AddStudent(c, userid) + if err != nil { + return nil, err + } + err = cs.store.InsertIntoUserCourses(c, userid) + if err != nil { + return nil, err + } + return c, nil +} + +func (cs *CourseService) RemoveFromRoster( + courseid string, + userid string, +) error { + c, err := cs.store.GetCourseByID(courseid) + if err != nil { + return err + } + _, err = cs.store.RemoveStudent(c, userid) + if err != nil { + return err + } + return nil +} + +func (cs *CourseService) DeleteCourse(courseid string) error { + err := cs.store.DeleteCourseByID(courseid) + if err != nil { + return err + } + return nil +} diff --git a/backend/internal/domain/course_test.go b/backend/internal/domain/course_test.go new file mode 100644 index 0000000..d94bc90 --- /dev/null +++ b/backend/internal/domain/course_test.go @@ -0,0 +1,100 @@ +package domain + +import ( + "errors" + "strconv" + "testing" + + "github.com/n30w/Darkspace/internal/models" +) + +func TestCourseService_CreateCourse(t *testing.T) { + us := NewCourseService(newMockCourseStore()) + + // cred is fake credentials. + course := &models.Course{ + Title: "Software Engineering", + Teachers: make([]string, 1), + } + teacherid := "teacherid123" + course.Teachers = append(course.Teachers, teacherid) + + got, _ := us.CreateCourse(course, teacherid) + + if got != nil { + t.Errorf("got %s", got) + } +} + +// ========= // +// MOCKS // +// ========= // + +func newMockCourseStore() *mockCourseStore { + return &mockCourseStore{ + id: 0, + byID: make(map[string]*models.Course), + byEmail: make(map[string]int), + byUsername: make(map[string]int), + } +} + +type mockCourseStore struct { + id int + byID map[string]*models.User + byEmail map[string]int + byUsername map[string]int +} +func (mcs *mockCourseStore) InsertCourse(c *models.Course) (string, error) +{ + mus.id += 1 + mus.byID[strconv.Itoa(mus.id)] = u + mus.byEmail[u.Email.String()] = mus.id + mus.byUsername[u.Username.String()] = mus.id + return nil +} + +func (mcs *mockCourseStore) GetCourseByID(courseid string) (*models.Course, error) +{ + if u, ok := mus.byEmail[c.String()]; !ok { + return mus.byID[strconv.Itoa(u)], errors.New("email already taken") + } + return nil, nil +} + +func (mcs *mockCourseStore) GetRoster(c string) ([]models.User, error) +{ + if u, ok := mus.byUsername[username.String()]; !ok { + return mus.byID[strconv.Itoa(u)], + errors.New("username already taken") + } + return nil, nil +} + +func (mcs *mockCourseStore) ChangeCourseName(c *models.Course, name string) error +{ + return nil +} + +func (mcs *mockCourseStore) AddStudent(c *models.Course, userid string) (*models.Course, error) + +{ + return nil, nil +} +func (mcs *mockCourseStore) AddTeacher(courseId, userId string) error +{ + return nil, nil +} +func (mcs *mockCourseStore) RemoveStudent(c *models.Course, userid string) (*models.Course, error) +{ + return nil, nil +} +func (mcs *mockCourseStore) CheckCourseProfessorDuplicate(courseName string, teacherId string) (bool, error) +{ + return nil, nil +} +func (mcs *mockCourseStore) InsertIntoUserCourses(c *models.Course, userid string) error +{ + return nil, nil +} + diff --git a/backend/internal/domain/credentials.go b/backend/internal/domain/credentials.go index af40150..13bdecb 100644 --- a/backend/internal/domain/credentials.go +++ b/backend/internal/domain/credentials.go @@ -1,8 +1,162 @@ package domain -type credentials interface { - // valid validates whether a certain type of credential - // is taken or fits to a defined parameters for what type - // of values are allowed. - valid() error +import ( + "errors" + "fmt" + "strings" + "unicode" + + "github.com/n30w/Darkspace/internal/models" +) + +// validateCredentials validates credentials using credentials interface +// method. Firstly, the credentials are checked if they are blank. +// Then, in each Valid() method, specific requirements for the credentials are +// checked. +func validateCredentials(c *models.User) error { + var err error + + err = c.Credentials.Username.Valid() + if err != nil { + return err + } + + err = c.Credentials.Password.Valid() + if err != nil { + return err + } + + err = c.Credentials.Email.Valid() + if err != nil { + return err + } + + err = c.Credentials.Membership.Valid() + if err != nil { + return err + } + + return nil +} + +// Password is a hashed string from the frontend. +type Password string + +func (p Password) Valid() error { + if p == "" { + return errors.New("password field empty") + } + + var ( + hasMinLen bool = len(p) >= 8 + hasUpper bool + hasLower bool + hasNumber bool + hasSpecialChar bool + ) + + for _, char := range p { + switch { + case unicode.IsUpper(char): + hasUpper = true + case unicode.IsLower(char): + hasLower = true + case unicode.IsDigit(char): + hasNumber = true + case unicode.IsPunct(char) || unicode.IsSymbol(char): + hasSpecialChar = true + } + } + + if !hasMinLen { + return errors.New("password must be at least 8 characters long") + } + if !hasUpper { + return errors.New("password must contain at least one uppercase letter") + } + if !hasLower { + return errors.New("password must contain at least one lowercase letter") + } + if !hasNumber { + return errors.New("password must contain at least one digit") + } + if !hasSpecialChar { + return errors.New("password must contain at least one special character") + } + + return nil +} + +func (p Password) String() string { + return string(p) +} + +// Username is a string defined by the user they can +// use to login. +type Username string + +func (u Username) Valid() error { + if len(u) == 0 { + return errors.New("username cannot be empty") + } + + if len(u) < 3 { + return errors.New("username must be at least 3 characters long") + } + + return nil +} + +func (u Username) String() string { + return string(u) +} + +// Email is a valid NYU email address. +type Email string + +func (e Email) Valid() error { + if len(e) == 0 { + return errors.New("email cannot be empty") + } + + if !strings.Contains(string(e), "nyu.edu") { + return errors.New("email must contain nyu.edu") + } + + // TODO check if its after the @ symbol. + if !strings.Contains(string(e), ".") { + return errors.New("email must have a TLD") + } + + atIndex := strings.Index(string(e), "@") + if !(atIndex > 2) { // Checking for the local part to be more than two + // characters. + return errors.New("email local part must be more than two characters") + } + + return nil +} + +func (e Email) String() string { + return string(e) +} + +// Membership defines the type of permissions that a user is default +// scoped to. There are only two valid Membership possibilities for +// a POST request can add or change in the database, 0 and 1. Although +// there are integers greater than 1 defined, such as ADMIN, +// this is not supposed to be accessible by the frontend, and therefore, +// not bothered to be checked. +type Membership int + +func (m Membership) Valid() error { + if m < 0 || m > 1 { + return errors.New("membership must either be 0 or 1") + } + + return nil +} + +func (m Membership) String() string { + return fmt.Sprintf("%d", m) } diff --git a/backend/internal/domain/credentials_test.go b/backend/internal/domain/credentials_test.go new file mode 100644 index 0000000..0942083 --- /dev/null +++ b/backend/internal/domain/credentials_test.go @@ -0,0 +1,197 @@ +package domain + +import ( + "github.com/n30w/Darkspace/internal/models" + "testing" +) + +func Test_validateCredentials(t *testing.T) { + valid := models.Credentials{ + Username: Username("smartbunnypants123"), + Password: Password("validPass12@vaso(#0jlkm.Q"), + Email: Email("scamyu@nyu.edu"), + Membership: Membership(0), + } + + u := &models.User{Credentials: valid} + err := validateCredentials(u) + if err != nil { + t.Errorf("%s", err) + } +} + +func TestEmail_Valid(t *testing.T) { + var e Email + + t.Run( + "empty field", func(t *testing.T) { + e = "" + err := e.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "does not contain nyu.edu", func(t *testing.T) { + e = "randomguy@yahoo.com" + err := e.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "no TLD", func(t *testing.T) { + e = "randomguy@nyu" + err := e.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "two character email", func(t *testing.T) { + e = "ab@nyu.edu" + err := e.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) +} + +func TestUsername_Valid(t *testing.T) { + var u Username + + t.Run( + "empty field", func(t *testing.T) { + u = "" + err := u.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "less than 3 characters", func(t *testing.T) { + u = "abc" + err := u.Valid() + if err != nil { + t.Errorf("invalid validity") + } + }, + ) +} + +func TestPassword_Valid(t *testing.T) { + var p Password + + t.Run( + "empty field", func(t *testing.T) { + p = "" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "too short", func(t *testing.T) { + p = "abc" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "No numbers", func(t *testing.T) { + p = "aBcdefghijk" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "One number", func(t *testing.T) { + p = "aBcdefghijk3" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "no special characters", func(t *testing.T) { + p = "aBcdefghijk39" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "all lowercase", func(t *testing.T) { + p = "abcdefghijk39" + err := p.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) +} + +func TestMembership_Valid(t *testing.T) { + var m Membership + + t.Run( + "less than 0", func(t *testing.T) { + m = -1 + err := m.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "greater than 1", func(t *testing.T) { + m = 2 + err := m.Valid() + if err == nil { + t.Errorf("invalid validity") + } + }, + ) + + t.Run( + "equal to 0", func(t *testing.T) { + m = 0 + err := m.Valid() + if err != nil { + t.Errorf("%s", err) + } + }, + ) + + t.Run( + "equal to 1", func(t *testing.T) { + m = 1 + err := m.Valid() + if err != nil { + t.Errorf("%s", err) + } + }, + ) +} diff --git a/backend/internal/domain/excel.go b/backend/internal/domain/excel.go new file mode 100644 index 0000000..4669855 --- /dev/null +++ b/backend/internal/domain/excel.go @@ -0,0 +1,151 @@ +package domain + +import ( + "fmt" + "io" + "path" + "strconv" + "strings" + + "github.com/n30w/Darkspace/internal/models" + "github.com/xuri/excelize/v2" +) + +type ExcelStore interface { + Get(path ...string) ([][]string, error) + Save(file *excelize.File, to string) (string, error) + Open(path ...string) (*excelize.File, error) + AddRow(f *excelize.File, row *[]interface{}, start string) error +} + +type ExcelService struct { + store ExcelStore +} + +func NewExcelService(e ExcelStore) *ExcelService { return &ExcelService{store: e} } + +// ReadSubmissions reads an Excel file from a path. This method is +// to be used when receiving an offline graded submission Excel sheet, +// which is submitted by the teacher. This method reads the +// Excel sheet and returns a slice of Submissions, which can then +// be put into the database. +func (es *ExcelService) ReadSubmissions(path string) ( + []models.Submission, + error, +) { + var submissions []models.Submission + + rows, err := es.store.Get(path) + if err != nil { + return nil, err + } + + // Remove the first element from rows, using [1:] + // The first element is just column headers for data + // in the Excel template. + rows = rows[1:] + + for _, row := range rows { + submission := models.Submission{} + + // Set the appropriate values for each submission + // model. The slice values are dictated from + // the column headers in the Excel template file. + submission.User.FullName = row[0] + submission.User.ID = row[1] + submission.Grade, _ = strconv.ParseFloat(row[2], 64) + submission.Feedback = row[3] + submission.ID = row[4] + + submissions = append(submissions, submission) + } + + return submissions, nil +} + +// WriteSubmissions writes to an Excel file. This file will be sent to the +// teacher for their offline grading use. p is the path which to save the file +// to. The name of the file is automatically generated. +// The path to the generated file is returned along with an error. +func (es *ExcelService) WriteSubmissions( + p, fileName string, + submissions []*models.Submission, +) (string, error) { + savePath := path.Join(p, fileName) + + // Open template. + f, err := es.store.Open() + if err != nil { + return "", err + } + + defer f.Close() + + // Write Course ID and Assignment ID to template. Uses the + // fileName to retrieve the Course ID and Assignment ID. + caId := strings.Split(fileName, "_") + row := &[]interface{}{ + caId[1], + caId[2], + } + + fmt.Printf("writing submissions \n") + + err = es.store.AddRow(f, row, "G2") + + // Write rows to template. + for i, submission := range submissions { + row := &[]interface{}{submission.User.FullName, submission.User.ID, submission.Grade, submission.Feedback, submission.ID} + + // Start in column A, increment downward. i+2 because + // i starts at 0, Excel rows start at 1, and the first + // row is used by column headers. + start := "A" + strconv.Itoa(i+2) + + err = es.store.AddRow(f, row, start) + if err != nil { + return "", err + } + } + + fmt.Printf("Saving to path: %s \n", savePath) + // Save the file to disk. + s, err := es.store.Save(f, savePath) + if err != nil { + return "", err + } + + // s should be a complete path with the generated file name. + return s, nil +} + +// Save takes an Excelize Excel file and saves it to a specified +// path, via to. +func (es *ExcelService) Save(f *excelize.File, to string) (string, error) { + p, err := es.store.Save(f, to) + if err != nil { + return "", err + } + + return p, nil +} + +// SendFile writes an Excel file to an io.Writer interface. For our +// use case, this will be an HTTP stream. +func (es *ExcelService) SendFile(path string, w io.Writer) error { + fmt.Printf("sending file \n") + + f, err := es.store.Open(path) + if err != nil { + return err + } + + defer f.Close() + + err = f.Write(w) + if err != nil { + return nil + } + + return nil +} diff --git a/backend/internal/domain/excel_test.go b/backend/internal/domain/excel_test.go new file mode 100644 index 0000000..1943eb8 --- /dev/null +++ b/backend/internal/domain/excel_test.go @@ -0,0 +1,159 @@ +package domain + +import ( + "fmt" + "math/rand" + "testing" + + "github.com/n30w/Darkspace/internal/models" +) + +// ========= // +// MOCKS // +// ========= // + +func newMockExcelStore() *mockExcelStore { + return &mockExcelStore{ + Students: make([]*models.User, 0), + Assignments: make([]*models.Assignment, 0), + Submissions: make([]*models.Submission, 0), + } +} +func ID() string { + + // Generate a random number of length 3 + randomNumber := rand.Intn(900) + 100 + + return fmt.Sprintf("%d", randomNumber) +} +func Print(mus *mockExcelStore, t *testing.T) { + for idx, student := range mus.Students { + t.Logf("Student %d: id(%s)", idx, student.ID) + } + for _, submission := range mus.Submissions { + t.Logf( + "Submission from User %s, Grade: %f, Feedback: %s", + submission.User.ID, + submission.Grade, + submission.Feedback, + ) + } +} + +func SetDatabase( + assignment int, + submission int, + students int, + mus *mockExcelStore, +) { + + roster := make([]string, 0) + course := &models.Course{ + Entity: models.Entity{ + ID: ID(), + }, + } + mus.Course = course + for i := 0; i < students; i++ { + student := &models.User{ + Entity: models.Entity{ + ID: ID(), + }, + } + roster = append(roster, student.ID) + mus.Students = append(mus.Students, student) + } + course.Roster = roster + + for i := 0; i < assignment; i++ { + assignment := &models.Assignment{ + Post: models.Post{ + Entity: models.Entity{ + ID: ID(), + }, + }} + mus.Assignments = append(mus.Assignments, assignment) + } + for j := 0; j < assignment; j++ { + for i := 0; i < submission; i++ { + sub := &models.Submission{ + Entity: models.Entity{ + ID: ID(), + }, + User: *mus.Students[i], + } + mus.Submissions = append(mus.Submissions, sub) + mus.Assignments[j].Submission = append( + mus.Assignments[j].Submission, + sub.ID, + ) + } + } +} + +type mockExcelStore struct { + Course *models.Course + Students []*models.User + Assignments []*models.Assignment + Submissions []*models.Submission +} + +func (mus *mockExcelStore) GetCourseByID(courseid string) ( + *models.Course, + error, +) { + return mus.Course, nil +} +func (mus *mockExcelStore) GetAssignmentById(assignmentId string) ( + *models.Assignment, + error, +) { + for _, assignment := range mus.Assignments { + if assignment.ID == assignmentId { + return assignment, nil + } + } + return nil, fmt.Errorf("no such assignment id") +} + +func (mus *mockExcelStore) GetSubmissionById(submissionId string) ( + *models.Submission, + error, +) { + for _, submission := range mus.Submissions { + if submission.ID == submissionId { + return submission, nil + } + } + return nil, fmt.Errorf("no such assignment id") +} + +func (mus *mockExcelStore) GradeSubmission( + grade float64, + submission *models.Submission, +) error { + submission.Grade = grade + for idx, sub := range mus.Submissions { + if sub.ID == submission.ID { + mus.Submissions[idx] = submission + return nil + } + } + + return fmt.Errorf("No such submission") +} + +func (mus *mockExcelStore) InsertSubmissionFeedback( + feedback string, + submission *models.Submission, +) error { + submission.Feedback = feedback + for idx, sub := range mus.Submissions { + if sub.ID == submission.ID { + mus.Submissions[idx] = submission + return nil + } + } + + return fmt.Errorf("No such submission") +} diff --git a/backend/internal/domain/file.go b/backend/internal/domain/file.go new file mode 100644 index 0000000..7bf6570 --- /dev/null +++ b/backend/internal/domain/file.go @@ -0,0 +1,54 @@ +package domain + +import ( + "fmt" + "io" + "mime/multipart" + "os" +) + +type FileStore interface { + CreateFile(path string) (*os.File, string, error) + CopyFile(f1 io.Writer, f2 io.Reader) error + fmt.Stringer +} + +type FileService struct { + store FileStore +} + +func NewFileService(store FileStore) *FileService { return &FileService{store: store} } + +// Save saves a file to disk. This is used for incoming +// files from the handlers. It returns a path to where the +// file was saved and an error. +func (fs *FileService) Save(name string, in multipart.File) (string, error) { + f, p, err := fs.store.CreateFile(name) + if err != nil { + return "", err + } + + defer f.Close() + + err = fs.store.CopyFile(f, in) + if err != nil { + return "", err + } + + return p, nil +} + +// GetFile opens a file at the specified path and returns it. +func (fs *FileService) GetFile(path string) (*os.File, error) { + // Open the file at the specified path + file, err := os.Open(path) + if err != nil { + return nil, err + } + + return file, nil +} + +func (fs *FileService) Path() string { + return fmt.Sprintf("%s", fs.store) +} diff --git a/backend/internal/domain/media.go b/backend/internal/domain/media.go index 1375494..ea68be4 100644 --- a/backend/internal/domain/media.go +++ b/backend/internal/domain/media.go @@ -1,23 +1,89 @@ package domain -import "time" - -type filetype int - -const ( - JPG filetype = iota - PNG - PDF - M4A - MP3 - TXT - NULL +import ( + "github.com/n30w/Darkspace/internal/models" ) -type Media struct { - Name string `json:"name"` - Uuid string `json:"uuid"` - DateUploaded time.Time `json:"date_uploaded"` - CourseAttributions []Course `json:"course_attributions"` - FileType string `json:"file_type"` +// announcement and discussion services +type MediaStore interface { + GetMediaById(id string) (*models.Media, error) + InsertMedia(media *models.Media) (*models.Media, error) + InsertMediaIntoCourse(m *models.Media) error + InsertMediaIntoAssignment(m *models.Media) error + InsertMediaIntoSubmission(m *models.Media) error + InsertMediaIntoCourseBanner(m *models.Media) error +} + +type MediaService struct { + store MediaStore +} + +func NewMediaService(m MediaStore) *MediaService { return &MediaService{store: m} } + +func (ms *MediaService) AddBanner( + media *models.Media, +) (*models.Media, error) { + media, err := ms.store.InsertMedia(media) + if err != nil { + return nil, err + } + + err = ms.store.InsertMediaIntoCourse(media) + if err != nil { + return nil, err + } + err = ms.store.InsertMediaIntoCourseBanner(media) + if err != nil { + return nil, err + } + + return media, nil +} + +func (ms *MediaService) AddAssignmentMedia( + media *models.Media, +) (*models.Media, error) { + media, err := ms.store.InsertMedia(media) + if err != nil { + return nil, err + } + err = ms.store.InsertMediaIntoAssignment(media) + if err != nil { + return nil, err + } + return media, nil +} + +func (ms *MediaService) AddSubmissionMedia( + media *models.Media, +) (*models.Media, error) { + media, err := ms.store.InsertMedia(media) + if err != nil { + return nil, err + } + err = ms.store.InsertMediaIntoSubmission(media) + if err != nil { + return nil, err + } + return media, nil +} + +// GetMedia retrieves a piece of media from a file system given a path. +// It does two things: finds a piece of media in the database by its +// path and, if it does find it, returns it as a struct representation. +func (ms *MediaService) GetMedia(id string) (*models.Media, error) { + var media *models.Media + var err error + + if id == models.DefaultImageId { + media = models.NewMedia("default_image", models.JPG) + return media, nil + } + + media, err = ms.store.GetMediaById(id) + if err != nil { + return nil, err + } + + return media, nil } diff --git a/backend/internal/domain/message.go b/backend/internal/domain/message.go new file mode 100644 index 0000000..2d46964 --- /dev/null +++ b/backend/internal/domain/message.go @@ -0,0 +1,99 @@ +package domain + +import ( + "fmt" + "time" + + "github.com/n30w/Darkspace/internal/models" +) + +// announcement and discussion services +type MessageStore interface { + InsertMessage(m *models.Message, courseid string) error + GetMessageById(messageid string) (*models.Message, error) + DeleteMessageByID(messageid string) error + ChangeMessageTitle(m *models.Message) (*models.Message, error) + ChangeMessageBody(m *models.Message) (*models.Message, error) + GetMessagesByCourse(courseid string) ([]string, error) +} + +type MessageService struct { + store MessageStore +} + +func NewMessageService(m MessageStore) *MessageService { return &MessageService{store: m} } + +// CreateAnnouncement inserts an announcement into the database +// using method parameters. +func (ms *MessageService) CreateAnnouncement( + title, description, + owner, courseId string, +) (*models.Message, error) { + msg := models.NewMessage(title, description, owner, true) + + msg.CreatedAt = time.Now() + + err := ms.store.InsertMessage(msg, courseId) + if err != nil { + return nil, err + } + + return msg, nil +} + +func (ms *MessageService) CreateMessage(m *models.Message, courseid string) (*models.Message, error) { + err := ms.store.InsertMessage(m, courseid) + if err != nil { + return nil, err + } + return m, nil +} + +func (ms *MessageService) UpdateMessage(messageid string, action string, updatedField string) (*models.Message, error) { + msg, err := ms.store.GetMessageById(messageid) + if err != nil { + return nil, err + } + if action == "title" { + msg.Post.Title = updatedField + msg, err = ms.store.ChangeMessageTitle(msg) + if err != nil { + return nil, err + } + } else if action == "body" { + msg.Post.Description = updatedField + msg, err = ms.store.ChangeMessageBody(msg) + if err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("%s is an invalid action", action) + } + return msg, nil +} + +func (ms *MessageService) DeleteMessage(messageid string) error { + + err := ms.store.DeleteMessageByID(messageid) + if err != nil { + return err + } + return nil +} + +func (ms *MessageService) ReadMessage(messageid string) (*models.Message, error) { + msg, err := ms.store.GetMessageById(messageid) + if err != nil { + return nil, err + } + + return msg, err +} +func (ms *MessageService) RetrieveMessages(courseid string) ([]string, error) { + + msgids, err := ms.store.GetMessagesByCourse(courseid) + if err != nil { + return nil, err + } + return msgids, err +} diff --git a/backend/internal/domain/pedagogy.go b/backend/internal/domain/pedagogy.go deleted file mode 100644 index 7d16ef3..0000000 --- a/backend/internal/domain/pedagogy.go +++ /dev/null @@ -1,69 +0,0 @@ -package domain - -import "time" - -type Post struct { - Title string - ID string - Description string - Media []Media - Date time.Time - Owner User -} - -type Assignment struct { - Post Post - Submission []Submission - Feedback string - Grade int - DueDate time.Time `json:"due_date"` -} - -func (a Assignment) addSubmission(submission Submission) { - a.Submission = append(a.Submission, submission) -} - -type Submission struct { - User User - FileType string - SubmissionTime time.Time - OnTime bool -} - -type Course struct { - Name string `json:"name"` - ID int64 `json:"id"` - Discussions [10]Discussion `json:"discussions"` - Teachers []User `json:"teachers"` - Roster []User `json:"roster"` - Assignments []Assignment `json:"assignments"` - Archived bool `json:"archived"` -} - -// Discussion contains anything related to communications, -// such as discussion posts and user messages. -type Discussion struct { - Post Post - Comments []Comment -} - -func (d Discussion) addComment(comment Comment) { - d.Comments = append(d.Comments, comment) -} - -// TODO: Linked lists -type Comment struct { - Post Post - Replies []Comment -} - -type Project struct { - Name string `json:"name"` - ID string `json:"id"` - Deadline time.Time `json:"deadline"` - MediaReferences []Media `json:"media_references"` - Members []User `json:"members"` - Discussion Discussion `json:"discussion"` - - ac *accessControl -} diff --git a/backend/internal/domain/service.go b/backend/internal/domain/service.go new file mode 100644 index 0000000..3d40ad3 --- /dev/null +++ b/backend/internal/domain/service.go @@ -0,0 +1,36 @@ +package domain + +import "github.com/n30w/Darkspace/internal/dal" + +type Service struct { + UserService *UserService + CourseService *CourseService + MessageService *MessageService + AssignmentService *AssignmentService + SubmissionService *SubmissionService + ExcelService *ExcelService + MediaService *MediaService + AuthenticationService *AuthenticationService + FileService *FileService +} + +func NewServices(s *dal.Store, e *dal.ExcelStore, f *dal.LocalVolume) *Service { + return &Service{ + UserService: NewUserService(s), + CourseService: NewCourseService(s), + MessageService: NewMessageService(s), + AssignmentService: NewAssignmentService(s), + SubmissionService: NewSubmissionService(s), + ExcelService: NewExcelService(e), + MediaService: NewMediaService(s), + AuthenticationService: NewAuthenticationService(s), + FileService: NewFileService(f), + } +} + +type action int + +const ( + Add action = iota + Delete +) diff --git a/backend/internal/domain/storage.go b/backend/internal/domain/storage.go new file mode 100644 index 0000000..2475e6e --- /dev/null +++ b/backend/internal/domain/storage.go @@ -0,0 +1,20 @@ +package domain + +type StorageStore interface { +} + +type StorageService struct { + store StorageStore +} + +// ReadFile reads a file into memory. It returns a slice +// of bytes and an error, if there is one. +func (s *StorageService) ReadFile(path string) ([]byte, error) { + return nil, nil +} + +// WriteFile writes a file to a file path using a slice +// of data bytes[]. +func (s *StorageService) WriteFile(path string, data []byte) error { + return nil +} diff --git a/backend/internal/domain/store.go b/backend/internal/domain/store.go new file mode 100644 index 0000000..eb9f10f --- /dev/null +++ b/backend/internal/domain/store.go @@ -0,0 +1,12 @@ +package domain + +type Store interface { + UserStore + CourseStore + MessageStore + AssignmentStore + AuthenticationStore + SubmissionStore + ExcelStore + FileStore +} diff --git a/backend/internal/domain/submission.go b/backend/internal/domain/submission.go new file mode 100644 index 0000000..b7a64ce --- /dev/null +++ b/backend/internal/domain/submission.go @@ -0,0 +1,167 @@ +package domain + +import ( + "fmt" + + "github.com/n30w/Darkspace/internal/models" +) + +type SubmissionStore interface { + GetSubmissions(assignmentId string) ([]*models.Submission, error) + GetSubmissionById(submissionId string) (*models.Submission, error) + GetSubmissionMedia(submission *models.Submission) (*models.Submission, error) + GetSubmissionIdByUserAndAssignment(netId string, assignmentId string) (string, error) + InsertSubmission(sub *models.Submission) ( + *models.Submission, + error, + ) + InsertSubmissionIntoAssignment(sub *models.Submission) (*models.Submission, error) + InsertSubmissionIntoUser(sub *models.Submission) (*models.Submission, error) + UpdateSubmission(submission *models.Submission) (*models.Submission, error) + DeleteSubmissionByID(id string) error +} + +type SubmissionService struct { + store SubmissionStore +} + +func NewSubmissionService(s SubmissionStore) *SubmissionService { + return &SubmissionService{store: s} +} + +func (ss *SubmissionService) CreateSubmission(s *models.Submission) ( + *models.Submission, + error, +) { + fmt.Printf("Inserting into submissions table...\n") + // Insert submission into submission table + s, err := ss.store.InsertSubmission(s) + if err != nil { + return nil, err + } + fmt.Printf("Inserting into assignment_submissions table...\n") + + // Insert submission into assignment_submissions table + s, err = ss.store.InsertSubmissionIntoAssignment(s) + if err != nil { + return nil, err + } + fmt.Printf("Inserting into user_submissions table...\n") + + // Insert submission into user_submissions table + s, err = ss.store.InsertSubmissionIntoUser(s) + if err != nil { + return nil, err + } + return s, nil +} + +func (ss *SubmissionService) GradeSubmission(grade int, feedback string, submissionid string) (*models.Submission, error) { + submission, err := ss.store.GetSubmissionById(submissionid) + if err != nil { + return nil, err + } + submission.Grade = float64(grade) + submission.Feedback = feedback + + _, err = ss.store.UpdateSubmission(submission) + if err != nil { + return nil, err + } + return submission, nil +} + +func (ss *SubmissionService) DeleteSubmission(id string) error { + err := ss.store.DeleteSubmissionByID(id) + if err != nil { + return err + } + return nil +} + +func (ss *SubmissionService) GetSubmission(id string) ( + *models.Submission, + error, +) { + submission, err := ss.store.GetSubmissionById(id) + if err != nil { + return nil, err + } + return submission, nil +} + +func (ss *SubmissionService) UpdateSubmission(id string) ( + *models.Submission, + error, +) { + + return nil, nil +} + +// GetUserSubmission retrieves the submission by a user for an assignment given +// a netId and assignmentId +func (ss *SubmissionService) GetUserSubmission(userId string, assignmentId string) ( + *models.Submission, + error, +) { + submissionId, err := ss.store.GetSubmissionIdByUserAndAssignment(userId, assignmentId) + if err != nil { + return nil, err + } + submission, err := ss.store.GetSubmissionById(submissionId) + if err != nil { + return nil, err + } + submission, err = ss.store.GetSubmissionMedia(submission) + if err != nil { + return nil, err + } + return submission, nil +} + +// GetSubmissions retrieves the submissions for a specific course given +// a Course ID and Assignment ID. It returns a slice of submissions +// for the given assignment. +func (ss *SubmissionService) GetSubmissions(assignmentId string) ( + []*models.Submission, + error, +) { + // Get all submissions using assignmentId. + submissions, err := ss.store.GetSubmissions(assignmentId) + if err != nil { + return nil, err + } + + return submissions, nil +} + +// UpdateSubmissions updates submissions from a slice of +// submissions. This is used for updating submission entries +// in the database from an Excel file. +func (ss *SubmissionService) UpdateSubmissions( + submissions []models.Submission, +) error { + var toDelete []*models.Submission + + // You can technically do this in one go, but not sure + // how to write that query... + for _, submission := range submissions { + s, err := ss.store.UpdateSubmission(&submission) + if err != nil { + return err + } + + toDelete = append(toDelete, s) + } + + // Since the update creates new rows, delete the old + // rows in the database. + for _, submission := range toDelete { + err := ss.store.DeleteSubmissionByID(submission.ID) + if err != nil { + return err + } + } + + return nil +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..77ab4e0 --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,178 @@ +package domain + +import ( + "fmt" + + "github.com/n30w/Darkspace/internal/models" +) + +type UserStore interface { + InsertUser(u *models.User) error + GetUserByID(u *models.User) (*models.User, error) + GetUserByEmail(c models.Credential) (*models.User, error) + DeleteCourseFromUser(u *models.User, courseid string) error + GetMembershipById(netid string) (*models.Credential, error) + GetUserCourses(u *models.User) ([]models.Course, error) +} + +type UserService struct { + store UserStore +} + +func NewUserService(us UserStore) *UserService { + return &UserService{store: us} +} + +func (us *UserService) ValidateUser(netid string, password string) error { + u := &models.User{ + Entity: models.Entity{ + ID: netid, + }, + } + + user, err := us.store.GetUserByID(u) + if err != nil { + return err + } + + if user.Password.String() != password { + return fmt.Errorf("password mismatch") + } + + return nil +} + +// CreateUser validates User model values, and if all is well, +// creates the user in the database. +func (us *UserService) CreateUser(um *models.User) error { + // TEMP + // m := &models.User{} + // First check if user exists. + // _, err := us.store.GetUserByID(m) + // _, err := us.store.GetUserByEmail(m.Email) + // if err != nil { + // return err + // } + + // Check if credentials are valid. + err := validateCredentials(um) + if err != nil { + return err + } + + // Check if email is already in use. + _, err = us.store.GetUserByEmail(um.Email) + if err == nil { + return fmt.Errorf("email already in use") + } + + // // Check if username is already in use. + // _, err = us.store.GetUserByUsername(um.Username) + // // Notice that err IS EQUAL TO nil and not NOT EQUAL TO. + // if err == nil { + // return fmt.Errorf("username already in use") + // } + + // If all is well... + err = us.store.InsertUser(um) + if err != nil { + return err + } + + return nil +} + +func (us *UserService) RetrieveHomepage() (*models.Homepage, error) { + // hp := &models.Homepage{} + + return nil, nil +} + +func (us *UserService) GetByID(userid string) (*models.User, error) { + // TEMP + m := &models.User{} + m.ID = userid + user, err := us.store.GetUserByID(m) + if err != nil { + return nil, err + } + + return user, nil +} + +// RetrieveFromUser currently is used for the homepage. It just retrieves +// the user's courses. +func (us *UserService) RetrieveFromUser( + userid string, +) ([]models.Course, error) { + // TEMP + m := &models.User{} + m.ID = userid + courses, err := us.store.GetUserCourses(m) + if err != nil { + return nil, err + } + return courses, err +} + +func (us *UserService) GetUserCourses(userId string) ([]models.Course, error) { + m := &models.User{ + Entity: models.Entity{ + ID: userId, + }, + } + + user, err := us.store.GetUserByID(m) + if err != nil { + return nil, err + } + + courses, err := us.store.GetUserCourses(user) + if err != nil { + return nil, err + } + + return courses, err +} + +func (us *UserService) GetMembership(netid string) (*models.Credential, error) { + membership, err := us.store.GetMembershipById(netid) + if err != nil { + return nil, err + } + return membership, nil +} + +func (us *UserService) UnenrollUserFromCourse( + userid string, + courseid string, +) error { + // TEMP + m := &models.User{} + user, err := us.store.GetUserByID(m) + if err != nil { + return err + } + err = us.store.DeleteCourseFromUser(user, courseid) + if err != nil { + return err + } + + return nil +} + +func (us *UserService) NewUsername(s string) Username { + return Username(s) +} + +func (us *UserService) NewPassword(s string) Password { + return Password(s) +} + +func (us *UserService) NewEmail(s string) Email { + return Email(s) +} + +func (us *UserService) NewMembership(d int) Membership { + return Membership(d) +} diff --git a/backend/internal/domain/user_test.go b/backend/internal/domain/user_test.go new file mode 100644 index 0000000..eac2721 --- /dev/null +++ b/backend/internal/domain/user_test.go @@ -0,0 +1,101 @@ +package domain + +import ( + "errors" + "strconv" + "testing" + + "github.com/n30w/Darkspace/internal/models" +) + +func TestUserService_CreateUser(t *testing.T) { + us := NewUserService(newMockUserStore()) + + // cred is fake credentials. + cred := models.Credentials{ + Username: Username("snow"), + Password: Password("buTter1290310923!09q3t"), + Email: Email("snow@nyu.edu"), + Membership: Membership(0), + } + + newUser, err := models.NewUser("abc123", cred, "donald duck") + + if err != nil { + t.Errorf("%v", err) + } + + got := us.CreateUser(newUser) + + if got != nil { + t.Errorf("got %s", got) + } +} + +// ========= // +// MOCKS // +// ========= // + +func newMockUserStore() *mockUserStore { + return &mockUserStore{ + id: 0, + byID: make(map[string]*models.User), + byEmail: make(map[string]int), + byUsername: make(map[string]int), + } +} + +type mockUserStore struct { + id int + byID map[string]*models.User + byEmail map[string]int + byUsername map[string]int +} + +func (mus *mockUserStore) InsertUser(u *models.User) error { + mus.id += 1 + mus.byID[strconv.Itoa(mus.id)] = u + mus.byEmail[u.Email.String()] = mus.id + mus.byUsername[u.Username.String()] = mus.id + return nil +} + +func (mus *mockUserStore) GetUserByID(u *models.User) ( + *models.User, + error, +) { + u = mus.byID[u.ID] + return u, nil +} + +func (mus *mockUserStore) GetUserByEmail(c models.Credential) ( + *models.User, + error, +) { + if u, ok := mus.byEmail[c.String()]; !ok { + return mus.byID[strconv.Itoa(u)], errors.New("email already taken") + } + return nil, nil +} + +func (mus *mockUserStore) GetUserByUsername(username models.Credential) ( + *models.User, + error, +) { + if u, ok := mus.byUsername[username.String()]; !ok { + return mus.byID[strconv.Itoa(u)], + errors.New("username already taken") + } + return nil, nil +} + +func (mus *mockUserStore) DeleteCourseFromUser( + u *models.User, + courseid string, +) error { + return nil +} + +func (mus *mockUserStore) GetMembershipById(id string) (*models.Credential, error) { + return nil, nil +} diff --git a/backend/internal/domain/users.go b/backend/internal/domain/users.go deleted file mode 100644 index 839fd52..0000000 --- a/backend/internal/domain/users.go +++ /dev/null @@ -1,114 +0,0 @@ -package domain - -import ( - "errors" - "fmt" - "time" -) - -type member uint8 - -const ( - STUDENT member = iota - TEACHER - ADMIN -) - -type User struct { - Username string `json:"username"` - Password string `json:"password"` - - // The NetID serves as a UUID. - Netid string `json:"netid"` - - // General user data - FullName string `json:"full_name"` - ProfilePicture Media `json:"profile_picture"` - JoinDate time.Time `json:"join_date"` - ModifiedDate time.Time `json:"modified_date"` - Projects []Project `json:"projects"` - Courses []Course `json:"courses"` - Bio string `json:"bio"` - - ac *accessControl -} - -//func (u User) createDiscussion( -// title string, -// description string, -// media []Media, -// date time.Time, -//) Discussion { -// return Discussion{ -// Name: title, -// Description: description, -// MediaReferences: media, -// } -//} - -type userConfig struct { - creds [4]credentials -} - -// func newUserConfig() userConfig { -// return userConfig{ -// creds: [4]credentials{ -// password{}, -// username("john"), -// netid("rra9981"), -// email("123@yahoo.com"), -// }, -// } -// } - -func (u userConfig) valid() error { - for _, v := range u.creds { - if err := v.valid(); err != nil { - return fmt.Errorf("invalid user configuration: %s", err) - } - } - return nil -} - -type password struct{ value []byte } - -func (p password) valid() error { - valid := false - if !valid { - return errors.New("password does not match criteria") - } - return nil -} - -type username string - -func (u username) valid() error { - // if username is already in DB, return false - valid := false - if !valid { - return errors.New("username already taken") - } - return nil -} - -type netid string - -func (n netid) valid() error { - valid := false - if !valid { - return errors.New("netid already in use") - } - return nil -} - -type email string - -func (e email) valid() error { - valid := false - if !valid { - return errors.New("email already in use") - } - return nil -} - -// Instantiate new users in database and session. diff --git a/backend/internal/models/credentials.go b/backend/internal/models/credentials.go new file mode 100644 index 0000000..5d2b1cb --- /dev/null +++ b/backend/internal/models/credentials.go @@ -0,0 +1,9 @@ +package models + +type Credential interface { + // Valid validates whether a certain type of credential + // is taken or fits to a defined parameters for what type + // of values are allowed. + Valid() error + String() string +} diff --git a/backend/internal/models/entity.go b/backend/internal/models/entity.go new file mode 100644 index 0000000..3118779 --- /dev/null +++ b/backend/internal/models/entity.go @@ -0,0 +1,20 @@ +package models + +import ( + "database/sql" + "time" +) + +// Entity defines a database object, in other words, +// an entity. This is a fundamental database object. I found this scheme from: +// https://github.com/g8rswimmer/go-data-access-example/blob/master/pkg/model/entity.go +type Entity struct { + // ID defines either an NYU NetID or a UUID. Both + // are strings. This should not be confused + // with the enumerated `id` field in the SQL database. + ID string `json:"id"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"-"` + DeletedAt sql.NullTime `json:"-"` +} diff --git a/backend/internal/models/media.go b/backend/internal/models/media.go new file mode 100644 index 0000000..18d5673 --- /dev/null +++ b/backend/internal/models/media.go @@ -0,0 +1,58 @@ +package models + +type FileType int + +const ( + JPG FileType = iota + PNG + PDF + M4A + MP3 + TXT + XLSX + NULL +) + +func (f FileType) String() string { + switch f { + case JPG: + return "jpg" + case PNG: + return "png" + case PDF: + return "pdf" + case M4A: + return "m4a" + case MP3: + return "mp3" + case TXT: + return "txt" + case XLSX: + return "xlsx" + case NULL: + return "" + } + return "" +} + +type Media struct { + Entity + FileName string `json:"name"` + AttributionsByType map[string]string `json:"attributions_by_type"` + FileType FileType `json:"file_type"` + FilePath string `json:"file_path"` +} + +func NewMedia(fileName string, fileType FileType) *Media { + return &Media{ + Entity: Entity{}, + FileName: fileName, + AttributionsByType: nil, + FileType: fileType, + FilePath: "", + } +} + +const ( + DefaultImageId = "default_image" +) \ No newline at end of file diff --git a/backend/internal/models/pages.go b/backend/internal/models/pages.go new file mode 100644 index 0000000..c745cad --- /dev/null +++ b/backend/internal/models/pages.go @@ -0,0 +1,6 @@ +package models + +type Homepage struct { + Courses []Course + Perms permissions +} diff --git a/backend/internal/models/pedagogy.go b/backend/internal/models/pedagogy.go new file mode 100644 index 0000000..226847f --- /dev/null +++ b/backend/internal/models/pedagogy.go @@ -0,0 +1,106 @@ +package models + +import "time" + +type Post struct { + Entity + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Media []string `json:"media,omitempty"` + Date string `json:"date,omitempty"` + Course string `json:"course,omitempty"` + + // Owner is often the Net ID. + Owner string `json:"owner,omitempty"` +} + +func NewPost(title, description, owner string) *Post { + return &Post{ + Title: title, + Description: description, + Owner: owner, + } +} + +type Assignment struct { + Post + Submission []string `json:"submission,omitempty"` + DueDate time.Time `json:"due_date"` +} + +func NewAssignment() *Assignment { + return &Assignment{} +} + +type Submission struct { + Entity + Grade float64 `json:"grade,omitempty"` + AssignmentId string `json:"assignment_id"` + User User + SubmissionTime time.Time + Media []string + Feedback string `json:"feedback"` + OnTime bool +} + +func NewSubmission() *Submission { + return &Submission{} +} + +// IsOnTime checks if an assignment's submission time is +// submitted on or before its due date, returning either true or false. +// This function is a variation of the one found here: +// https://stackoverflow.com/a/34100548/20087581 +func (s *Submission) IsOnTime(due time.Time) bool { + loc, _ := time.LoadLocation("UTC") + + dueDate := due.In(loc) + s.SubmissionTime = s.SubmissionTime.In(loc) + dur := dueDate.Sub(s.SubmissionTime) + + // If the duration is less than 0, that means the assignment is + // not on time. + return dur.Seconds() < 0 +} + +type Course struct { + Entity + Title string `json:"name"` + Description string `json:"description"` + Messages [10]string `json:"discussions"` //announcements + discussions + Teachers []string `json:"teachers"` + Roster []string `json:"roster"` + Assignments []string `json:"assignments"` + Archived bool `json:"archived"` + + // UUID of the banner + Banner string `json:"banner"` +} + +type Message struct { + Post + Comments []string + Type bool // false if discussion, true if announcement +} + +func NewMessage(title, description, owner string, t bool) *Message { + return &Message{ + Post: *NewPost(title, description, owner), + Type: t, + } +} + +type Comment struct { + Post + ID string + Replies []string +} + +// type Project struct { +// Name string `json:"name"` +// string string `json:"id"` +// Deadline time.Time `json:"deadline"` +// MediaReferences []MediaId `json:"media_references"` +// Members []User `json:"members"` +// Discussion Discussion `json:"discussion"` +// } diff --git a/backend/internal/domain/permission.go b/backend/internal/models/permission.go similarity index 68% rename from backend/internal/domain/permission.go rename to backend/internal/models/permission.go index a58a79f..46cdb1f 100644 --- a/backend/internal/domain/permission.go +++ b/backend/internal/models/permission.go @@ -1,14 +1,57 @@ -package domain +package models -import "strings" +import ( + "errors" + "strings" +) + +// member defines the affiliation of a user, whether they are a student, +// a teacher, or an administrator. In other words, +// it defines what group someone is a part of. +// The frontend will send either a 0 for STUDENT or a 1 for TEACHER. +// The affiliation ADMIN is only created server-side. +// The member type implements the Credential interface. +type member uint8 + +const ( + STUDENT member = iota + TEACHER + ADMIN +) + +// String returns the string representation of the membership type. +func (m member) String() string { + switch m { + case STUDENT: + return "STUDENT" + case TEACHER: + return "TEACHER" + case ADMIN: + return "ADMIN" + default: + return "" + } +} + +// Valid returns an error, checking whether the membership value +// provided is even valid. +func (m member) Valid() error { + if m > 2 || m < 0 { + return errors.New("invalid membership enumeration") + } + + return nil +} type scope uint8 const ( // Determines what one can do with themselves. + SELF scope = iota // Scopes for general pedagogy. + COURSE QUIZ ASSIGNMENT @@ -16,8 +59,9 @@ const ( PROJECT // Object specific and contextual scopes. + COMMENT - MEDIA + MEDIAS // MEDIAS has an "s", in order to differentiate between MEDIA SUBMIT ) @@ -43,6 +87,9 @@ func createPermissions() permissions { ASSIGNMENT: permission{}, DISCUSSION: permission{}, PROJECT: permission{}, + COMMENT: permission{}, + MEDIAS: permission{}, + SUBMIT: permission{}, } } @@ -119,29 +166,29 @@ func fromString(s string) permission { return p } -// accessControl defines a user's membership and their permissions. +// AccessControl defines a user's membership and their permissions. // Essentially, the scope of their abilities. It abstracts away type permissions // in order to conceal behavior and interference in the higher levels // of the API. AccessControl exists on pedagogical types or users as a pointer. // Reason being, remember that in Go, pointers have a null value as their // zero value. This means that a null value has the meaning that an object // has no permissions at all. -type accessControl struct { +type AccessControl struct { perms permissions } -func (a accessControl) read(s scope) bool { +func (a AccessControl) Read(s scope) bool { return a.perms[s].read } -func (a accessControl) write(s scope) bool { +func (a AccessControl) Write(s scope) bool { return a.perms[s].write } -func (a accessControl) update(s scope) bool { +func (a AccessControl) Update(s scope) bool { return a.perms[s].update } -func (a accessControl) delete(s scope) bool { +func (a AccessControl) Delete(s scope) bool { return a.perms[s].delete } diff --git a/backend/internal/domain/permission_test.go b/backend/internal/models/permission_test.go similarity index 99% rename from backend/internal/domain/permission_test.go rename to backend/internal/models/permission_test.go index a67d1e5..b1cdb09 100644 --- a/backend/internal/domain/permission_test.go +++ b/backend/internal/models/permission_test.go @@ -1,4 +1,4 @@ -package domain +package models import ( "fmt" diff --git a/backend/internal/models/tables.go b/backend/internal/models/tables.go new file mode 100644 index 0000000..bcd4343 --- /dev/null +++ b/backend/internal/models/tables.go @@ -0,0 +1,55 @@ +package models + +type Table int + +const ( + USERS Table = iota + COURSES + MEDIA + PROJECTS + MESSAGES + ASSIGNMENTS + SUBMISSIONS + USER_COURSES + COURSE_MESSAGES + COURSE_TEACHERS + COURSE_ROSTER + COURSE_ASSIGNMENTS + ASSIGNMENT_SUBMISSIONS + MESSAGE_MEDIA +) + +func (t Table) String() string { + switch t { + case USERS: + return "users" + case COURSES: + return "courses" + case MEDIA: + return "media" + case PROJECTS: + return "projects" + case MESSAGES: + return "messages" + case ASSIGNMENTS: + return "assignments" + case SUBMISSIONS: + return "submissions" + case USER_COURSES: + return "user_courses" + case COURSE_MESSAGES: + return "course_messages" + case COURSE_TEACHERS: + return "course_teachers" + case COURSE_ROSTER: + return "course_roster" + case COURSE_ASSIGNMENTS: + return "course_assignments" + case ASSIGNMENT_SUBMISSIONS: + return "assignment_submissions" + case MESSAGE_MEDIA: + return "message_media" + default: + return "invalid table" + } +} diff --git a/backend/internal/models/tables_test.go b/backend/internal/models/tables_test.go new file mode 100644 index 0000000..d252777 --- /dev/null +++ b/backend/internal/models/tables_test.go @@ -0,0 +1,46 @@ +package models + +import "testing" + +func TestTable_String(t *testing.T) { + tests := []struct { + table Table + expected string + }{ + {USERS, "users"}, + {COURSES, "courses"}, + {MEDIA, "media"}, + {PROJECTS, "projects"}, + {MESSAGES, "messages"}, + {ASSIGNMENTS, "assignments"}, + {SUBMISSIONS, "submissions"}, + {USER_COURSES, "user_courses"}, + {COURSE_MESSAGES, "course_messages"}, + {COURSE_TEACHERS, "course_teachers"}, + {COURSE_ROSTER, "course_roster"}, + {COURSE_ASSIGNMENTS, "course_assignments"}, + {ASSIGNMENT_SUBMISSIONS, "assignment_submissions"}, + {MESSAGE_MEDIA, "message_media"}, + } + + for _, tc := range tests { + t.Run( + tc.expected, func(t *testing.T) { + result := tc.table.String() + if result != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, result) + } + }, + ) + } + + // Test the default case + t.Run( + "invalid", func(t *testing.T) { + result := Table(-1).String() + if result != "invalid table" { + t.Errorf("Expected 'invalid table', got '%s'", result) + } + }, + ) +} diff --git a/backend/internal/models/tokens.go b/backend/internal/models/tokens.go new file mode 100644 index 0000000..1fe11f1 --- /dev/null +++ b/backend/internal/models/tokens.go @@ -0,0 +1,80 @@ +package models + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base32" + "errors" + "time" +) + +const ( + ScopeActivation = "activation" + ScopeAuthentication = "authentication" +) + +// Token is a stateful authentication tool to validate a user's identity. +// It implements the Credential interface. +type Token struct { + Plaintext string `json:"token"` + Hash []byte `json:"-"` + NetID string `json:"-"` + Expiry time.Time `json:"expiry"` + Scope string `json:"-"` +} + +// GenerateToken creates a new token in the database. It returns a token struct. +func GenerateToken( + netId string, + ttl time.Duration, scope string, +) (*Token, error) { + token := &Token{ + NetID: netId, + Expiry: time.Now().Add(ttl), + Scope: scope, + } + + randomBytes := make([]byte, 16) + + _, err := rand.Read(randomBytes) + if err != nil { + return nil, err + } + + token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) + + hash := sha256.Sum256([]byte(token.Plaintext)) + token.Hash = hash[:] + + return token, nil +} + +func GenerateTokenHash(token string) []byte { + // Generate random bytes for additional entropy + randomBytes := make([]byte, 16) + _, err := rand.Read(randomBytes) + if err != nil { + // Handle error + return nil + } + // Hash the combined token and entropy + hash := sha256.Sum256([]byte(token)) + + return hash[:] +} + +func (t Token) String() string { + return t.Plaintext +} + +func (t Token) Valid() error { + if t.Plaintext == "" { + return errors.New("token must be provided") + } + + if len(t.Plaintext) != 26 { + return errors.New("token must be 26 bytes long") + } + + return nil +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..1622588 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,73 @@ +package models + +import ( + "database/sql" + "time" +) + +// Credentials are user credentials gathered from the JSON request body. +// They represent custom types that implement the credential interface method. +type Credentials struct { + Username Credential `json:"username,omitempty"` + Password Credential `json:"password,omitempty"` + Email Credential `json:"email,omitempty"` + // Membership = 1 if teacher, 0 if student + Membership Credential `json:"membership,omitempty"` +} + +// User represents the concept of a User in a database. +// It is composed of an entity, the basic unit of a database object. +// The ID in this case will be the NetID, +// which is retrieved from a consumer during an API hit. +type User struct { + Entity + Credentials + *AccessControl + + // General user information. + FullName string `json:"full_name"` + + ProfilePicture Media `json:"profile_picture,omitempty"` + + // Projects []Project `json:"projects,omitempty"` + Courses []string `json:"courses,omitempty"` + Bio string `json:"bio,omitempty"` +} + +// NewUser creates a new user based on provided parameter +// information. It also sets the default access permissions +// and membership. +func NewUser(netId string, c Credentials, fullName string) (*User, error) { + var err error + + err = c.Username.Valid() + if err != nil { + return nil, err + } + + err = c.Password.Valid() + if err != nil { + return nil, err + } + + err = c.Email.Valid() + if err != nil { + return nil, err + } + + err = c.Membership.Valid() + if err != nil { + return nil, err + } + + return &User{ + Entity: Entity{ + ID: netId, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + DeletedAt: sql.NullTime{}, + }, + Credentials: c, + FullName: fullName, + }, nil +} diff --git a/backend/remote/db.Dockerfile b/backend/remote/db.Dockerfile new file mode 100644 index 0000000..97eaa89 --- /dev/null +++ b/backend/remote/db.Dockerfile @@ -0,0 +1,6 @@ +FROM postgres:16 +LABEL authors="Neo" + +# Uses dev-init.sql to initialize the database for development, +# with appropriate tables and data. +COPY development/dev-init.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/backend/remote/development/compose.yaml b/backend/remote/development/compose.yaml new file mode 100644 index 0000000..42e047f --- /dev/null +++ b/backend/remote/development/compose.yaml @@ -0,0 +1,25 @@ +name: darkspace-dev + +services: + database: + build: + context: .. + dockerfile: ./db.Dockerfile + container_name: db-postgres + environment: + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + ports: + - ${DB_PORT}:5432 + volumes: + - db-data:/data/postgres + networks: + - dksp + +networks: + dksp: + driver: bridge + +volumes: + db-data: diff --git a/backend/remote/development/db.Dockerfile b/backend/remote/development/db.Dockerfile new file mode 100644 index 0000000..1c57fdd --- /dev/null +++ b/backend/remote/development/db.Dockerfile @@ -0,0 +1,6 @@ +FROM postgres:16 +LABEL authors="Neo" + +# Uses dev-init.sql to initialize the database for testing, +# with appropriate tables and data. +COPY test-init.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/backend/remote/development/dev-init.sql b/backend/remote/development/dev-init.sql new file mode 100644 index 0000000..2e48c9c --- /dev/null +++ b/backend/remote/development/dev-init.sql @@ -0,0 +1,813 @@ +-- Initial setup +CREATE EXTENSION IF NOT EXISTS "citext"; + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users Table +CREATE TABLE IF NOT EXISTS users ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + net_id VARCHAR UNIQUE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + deleted_at TIMESTAMP WITHOUT TIME ZONE, + full_name VARCHAR, + profile_picture_id UUID, + bio TEXT, + username VARCHAR NOT NULL, + password VARCHAR NOT NULL, + email VARCHAR NOT NULL, + membership INT NOT NULL +); + +-- Media Table +CREATE TABLE IF NOT EXISTS media ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR NOT NULL, + path VARCHAR NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Courses Table +CREATE TABLE IF NOT EXISTS courses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + archived BOOLEAN NOT NULL DEFAULT FALSE, + banner_id UUID REFERENCES media(id) ON + DELETE + SET + NULL +); + +-- Projects Table +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR NOT NULL, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Messages Table +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + date TIMESTAMP WITHOUT TIME ZONE, + type BOOLEAN +); + +-- Assignments Table +CREATE TABLE IF NOT EXISTS assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + date TIMESTAMP WITHOUT TIME ZONE, + due_date TIMESTAMP WITHOUT TIME ZONE +); + +-- Submissions Table +CREATE TABLE IF NOT EXISTS submissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + file_type VARCHAR, + submission_time TIMESTAMP WITHOUT TIME ZONE, + on_time BOOLEAN, + grade FLOAT, + feedback VARCHAR +); + +----------------- +--- JUNCTIONS --- +----------------- +-- Junction Table for Users and Courses (Many-to-Many) +CREATE TABLE IF NOT EXISTS user_courses ( + user_net_id VARCHAR REFERENCES users(net_id) ON + DELETE + CASCADE, + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + PRIMARY KEY (user_net_id, course_id) +); + +-- Junction Table for Users and Assignments (Many-to-Many) +CREATE TABLE IF NOT EXISTS user_assignments ( + user_net_id VARCHAR REFERENCES users(net_id) ON + DELETE + CASCADE, + assignment_id UUID REFERENCES assignments(id) ON + DELETE + CASCADE, + PRIMARY KEY (user_net_id, assignment_id) +); + +-- Junction Table for Users and Submissions (Many-to-Many) +CREATE TABLE IF NOT EXISTS user_submissions ( + user_net_id VARCHAR REFERENCES users(net_id) ON + DELETE + CASCADE, + submission_id UUID REFERENCES submissions(id) ON + DELETE + CASCADE, + PRIMARY KEY (user_net_id, submission_id) +); + +-- Junction Table for Courses and Messages (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_messages ( + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + message_id UUID REFERENCES messages(id) ON + DELETE + CASCADE, + PRIMARY KEY (course_id, message_id) +); + +-- Junction Table for Courses and Teachers (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_teachers ( + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + teacher_id VARCHAR REFERENCES users(net_id) ON + DELETE + CASCADE, + PRIMARY KEY (course_id, teacher_id) +); + +-- Junction Table for Courses and Roster (Students) (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_roster ( + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + student_id VARCHAR REFERENCES users(net_id) ON + DELETE + CASCADE, + PRIMARY KEY (course_id, student_id) +); + +-- Junction Table for Courses and Assignments (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_assignments ( + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + assignment_id UUID REFERENCES assignments(id) ON + DELETE + CASCADE, + PRIMARY KEY (course_id, assignment_id) +); + +-- Junction Table for Assignments and Submissions (Many-to-Many) +CREATE TABLE IF NOT EXISTS assignment_submissions ( + assignment_id UUID REFERENCES assignments(id) ON + DELETE + CASCADE, + submission_id UUID REFERENCES submissions(id) ON + DELETE + CASCADE, + PRIMARY KEY (assignment_id, submission_id) +); + +-- Junction Table for Messages and Media (Many-to-Many) +CREATE TABLE IF NOT EXISTS message_media ( + message_id UUID REFERENCES messages(id) ON + DELETE + CASCADE, + media_id UUID REFERENCES media(id) ON + DELETE + CASCADE, + PRIMARY KEY (message_id, media_id) +); + +-- Authentication Table +CREATE TABLE IF NOT EXISTS tokens ( + hash bytea PRIMARY KEY, + net_id VARCHAR UNIQUE REFERENCES users(net_id) ON DELETE CASCADE, + expiry timestamp(0) with time zone NOT NULL, + scope text NOT NULL +); + +-- Junction Table for Course and Media (One to One) +CREATE TABLE IF NOT EXISTS course_media ( + course_id UUID REFERENCES courses(id) ON + DELETE + CASCADE, + media_id UUID REFERENCES media(id) ON + DELETE + CASCADE, + media_path VARCHAR, + PRIMARY KEY (course_id, media_id) +); + +-- Junction Table for Assignment and Media (One to One) +CREATE TABLE IF NOT EXISTS assignment_media ( + assignment_id UUID REFERENCES assignments(id) ON + DELETE + CASCADE, + media_id UUID REFERENCES media(id) ON + DELETE + CASCADE, + media_path VARCHAR, + PRIMARY KEY (assignment_id, media_id) +); + +-- Junction Table for Submission and Media (One to One) +CREATE TABLE IF NOT EXISTS submission_media ( + submission_id UUID REFERENCES submissions(id) ON + DELETE + CASCADE, + media_id UUID REFERENCES media(id) ON + DELETE + CASCADE, + media_path VARCHAR, + PRIMARY KEY (submission_id, media_id) +); + +-- Adding foreign key constraints after all tables are established and maintain direct single relationships +-- Use a cascade deletion. +ALTER TABLE + projects +ADD + COLUMN user_net_id VARCHAR REFERENCES users(net_id) ON +DELETE + CASCADE; + +ALTER TABLE + assignments +ADD + COLUMN media_id UUID REFERENCES media(id) ON +DELETE +SET + NULL; + +ALTER TABLE + assignments +ADD + COLUMN course_id UUID REFERENCES courses(id) ON +DELETE +SET + NULL; + +ALTER TABLE + assignments +ADD + COLUMN owner_id INT REFERENCES users(id) ON +DELETE +SET + NULL; + +ALTER TABLE + submissions +ADD + COLUMN user_id VARCHAR REFERENCES users(net_id) ON +DELETE + CASCADE; + +-- Foreign key for profile picture which relates to the Media table +ALTER TABLE + users +ADD + CONSTRAINT fk_profile_picture FOREIGN KEY (profile_picture_id) REFERENCES media (id) ON +DELETE +SET + NULL; + +-- Insert dummy users +-- Note: Insert users before courses since courses might reference users' net_id if needed +INSERT INTO + users ( + net_id, + created_at, + updated_at, + username, + password, + email, + membership, + full_name + ) +VALUES + ( + 'abc123', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'jcena', + 'password123', + 'abc123@nyu.edu', + 0, + 'John Cena' + ), + ( + 'xyz789', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'mmiller', + 'mypass789', + 'xyz789@example.com', + 1, + 'Mike Miller' + ), + ( + 'def456', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'ajackson', + 'pass456', + 'def456@example.com', + 0, + 'Alice Jackson' + ), + ( + 'uvw321', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'ksmith', + 'pass321', + 'uvw321@example.com', + 1, + 'Kevin Smith' + ), + ( + 'ghi987', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'jdoe', + 'mysecretpass', + 'ghi987@example.com', + 0, + 'Jane Doe' + ); + +-- Insert dummy courses +-- Removed the net_id column since it's now intended to be managed through a junction table or direct reference in projects, not stored directly in courses +INSERT INTO + courses (title, description, created_at, updated_at) +VALUES + ( + 'Introduction to Computer Science', + 'Basic concepts of computer programming', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'Advanced Mathematics', + 'In-depth coverage of calculus and linear algebra', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'Modern Art History', + 'Exploration of art from the 19th century to present', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'Environmental Science', + 'Study of climate change and environmental impact', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'Business Management', + 'Principles and practices in managing modern businesses', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ); + +-- Courses with preset ID for test operations. +INSERT INTO + courses(id, title, description, created_at, updated_at) +VALUES + ( + 'c3b34a9f-8f59-4818-a684-9cda56f42d02', + 'Clown Foundations', + 'Learn how to be a clown', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + '98e64e88-b989-49a0-bbfd-76e158bac634', + 'Delete This Course', + 'In testing, this course should be deleted', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ); + +-- Insert dummy assignments +-- Using the new structure without course_id in the initial insert. Instead, use the junction table to link courses and assignments if needed +INSERT INTO + assignments (title, description, date, due_date) +VALUES + ( + 'Quiz 1', + 'Quiz on basic programming concepts', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + interval '7 days' + ), + ( + 'Calculus Exam', + 'Midterm exam on calculus topics', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + interval '14 days' + ), + ( + 'Art Essay', + 'Essay on modern art movements', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + interval '10 days' + ), + ( + 'Climate Report', + 'Group report on climate change effects', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + interval '20 days' + ), + ( + 'Management Case Study', + 'Analysis of a business case study', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + interval '12 days' + ); + +-- Insert dummy messages +-- Updated to avoid using ARRAY and using the junction table instead +INSERT INTO + messages (title, description, date, type) +VALUES + ( + 'Welcome!', + 'Welcome to the course on Computer Science', + CURRENT_TIMESTAMP, + TRUE + ), + ( + 'Assignment Reminder', + 'Remember to submit the calculus exam by Friday', + CURRENT_TIMESTAMP, + FALSE + ), + ( + 'Field Trip', + 'Field trip to modern art museum next week', + CURRENT_TIMESTAMP, + TRUE + ), + ( + 'Guest Lecture', + 'Upcoming guest lecture on renewable energy', + CURRENT_TIMESTAMP, + TRUE + ), + ( + 'Project Groups', + 'Project groups for the case study have been assigned', + CURRENT_TIMESTAMP, + FALSE + ); + +-- Insert dummy projects +-- Note: Assuming that user_net_id refers to a single user managing the project +INSERT INTO + projects ( + name, + description, + created_at, + updated_at, + user_net_id + ) +VALUES + ( + 'Database Design', + 'Project focusing on designing efficient databases', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'abc123' + ), + ( + 'Statistics Software', + 'Develop statistical software using Python', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'xyz789' + ), + ( + 'Art Exhibition', + 'Organize a virtual art exhibition', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'def456' + ), + ( + 'Water Quality', + 'Study on water quality in urban areas', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'uvw321' + ), + ( + 'Startup Plan', + 'Create a business plan for a new startup', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + 'ghi987' + ); + +-- Insert dummy submissions +-- Note: Assuming each submission is linked to a single user +INSERT INTO + submissions ( + user_id, + submission_time, + on_time, + grade, + feedback + ) +VALUES + ( + ( + SELECT + net_id + FROM + users + WHERE + username = 'jcena' + ), + CURRENT_TIMESTAMP, + TRUE, + 85, + 'Good effort, but watch out for syntax errors' + ), + ( + ( + SELECT + net_id + FROM + users + WHERE + username = 'mmiller' + ), + CURRENT_TIMESTAMP, + FALSE, + 78, + 'Late submission, but well-written content' + ), + ( + ( + SELECT + net_id + FROM + users + WHERE + username = 'ajackson' + ), + CURRENT_TIMESTAMP, + TRUE, + 92, + 'Excellent analysis and creativity' + ), + ( + ( + SELECT + net_id + FROM + users + WHERE + username = 'ksmith' + ), + CURRENT_TIMESTAMP, + TRUE, + 88, + 'Good collaboration, impressive research' + ), + ( + ( + SELECT + net_id + FROM + users + WHERE + username = 'jdoe' + ), + CURRENT_TIMESTAMP, + TRUE, + 90, + 'Very thorough and well-structured report' + ); + +-- Link arbitrary assignments to arbitrary submissions. +INSERT INTO assignment_submissions (assignment_id, submission_id) +SELECT a.id, s.id +FROM assignments a + CROSS JOIN submissions s +WHERE NOT EXISTS ( + SELECT 1 FROM assignment_submissions asp + WHERE asp.assignment_id = a.id AND asp.submission_id = s.id +); + +-- Inserting courses for John Cena into the user_courses junction table +INSERT INTO + user_courses (user_net_id, course_id) +VALUES + ( + 'abc123', + ( + SELECT + id + FROM + courses + WHERE + title = 'Introduction to Computer Science' + ) + ), + ( + 'abc123', + ( + SELECT + id + FROM + courses + WHERE + title = 'Advanced Mathematics' + ) + ), + ( + 'abc123', + ( + SELECT + id + FROM + courses + WHERE + title = 'Modern Art History' + ) + ), + ( + 'abc123', + ( + SELECT + id + FROM + courses + WHERE + title = 'Environmental Science' + ) + ), + ( + 'abc123', + ( + SELECT + id + FROM + courses + WHERE + title = 'Business Management' + ) + ), + ('abc123', 'c3b34a9f-8f59-4818-a684-9cda56f42d02'), + -- Clown Foundations + ('abc123', '98e64e88-b989-49a0-bbfd-76e158bac634'); + +-- Delete This Course +-- Inserting teachers for courses into the course_teachers junction table +INSERT INTO + course_teachers (course_id, teacher_id) +VALUES + ('c3b34a9f-8f59-4818-a684-9cda56f42d02', 'xyz789'), + -- Clown Foundations + -- ('c3b34a9f-8f59-4818-a684-9cda56f42d02', 'def456'); -- Clown Foundations + -- ('c3b34a9f-8f59-4818-a684-9cda56f42d02', (SELECT net_id FROM users WHERE username = 'Kevin Smith')), -- Clown Foundations + -- ('98e64e88-b989-49a0-bbfd-76e158bac634', (SELECT net_id FROM users WHERE username = 'Alice Jackson')), -- Delete This Course + ( + ( + SELECT + id + FROM + courses + WHERE + title = 'Introduction to Computer Science' + ), + ( + SELECT + net_id + FROM + users + WHERE + full_name = 'Kevin Smith' + ) + ), + ( + ( + SELECT + id + FROM + courses + WHERE + title = 'Advanced Mathematics' + ), + ( + SELECT + net_id + FROM + users + WHERE + full_name = 'Kevin Smith' + ) + ), + ( + ( + SELECT + id + FROM + courses + WHERE + title = 'Modern Art History' + ), + ( + SELECT + net_id + FROM + users + WHERE + full_name = 'Jane Doe' + ) + ), + ( + ( + SELECT + id + FROM + courses + WHERE + title = 'Environmental Science' + ), + ( + SELECT + net_id + FROM + users + WHERE + full_name = 'Alice Jackson' + ) + ), + ( + ( + SELECT + id + FROM + courses + WHERE + title = 'Business Management' + ), + ( + SELECT + net_id + FROM + users + WHERE + full_name = 'Mike Miller' + ) + ); + +-- Insert a new course taught by user with net_id 'xyz789' +INSERT INTO courses (id, title, description, created_at, updated_at) +VALUES ('018f677f-1bf6-7b6a-aa02-1e2cff5c1c22', 'Data Science Fundamentals', 'Introduction to Data Science Concepts and Software', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- Assuming the above insertion returns a course_id, we use it to insert into course_teachers and user_courses +-- Let's assume the new course ID is 'd51a5442-df82-4c27-a9c3-5018a3ec3e91' for demonstration purposes. +-- Make sure to obtain the actual course ID from your database context or sequence. + +INSERT INTO course_teachers (course_id, teacher_id) +VALUES ('018f677f-1bf6-7b6a-aa02-1e2cff5c1c22', 'xyz789'); + +INSERT INTO user_courses (user_net_id, course_id) +VALUES ('xyz789', '018f677f-1bf6-7b6a-aa02-1e2cff5c1c22'), +('abc123', '018f677f-1bf6-7b6a-aa02-1e2cff5c1c22'), +('def456', '018f677f-1bf6-7b6a-aa02-1e2cff5c1c22'), +('ghi987', '018f677f-1bf6-7b6a-aa02-1e2cff5c1c22'); + +-- Insert students into the course_roster for the new course 'Data Science Fundamentals' +-- Add students by their net_id to the new course +INSERT INTO course_roster (course_id, student_id) +VALUES + ('018f677f-1bf6-7b6a-aa02-1e2cff5c1c22', 'abc123'), -- Assuming 'abc123' is a student + ('018f677f-1bf6-7b6a-aa02-1e2cff5c1c22', 'def456'), -- Assuming 'def456' is another student + ('018f677f-1bf6-7b6a-aa02-1e2cff5c1c22', 'ghi987'); -- Assuming 'ghi987' is another student + +-- Insert arbitrary assignment +INSERT INTO course_assignments (course_id, assignment_id) +VALUES ( + (SELECT id FROM courses WHERE title = 'Data Science Fundamentals'), + (SELECT id FROM assignments WHERE title = 'Management Case Study') + ); + +SELECT * FROM courses; +SELECT * FROM assignments; +SELECT * FROM messages; +SELECT * FROM projects; +SELECT * FROM submissions; +SELECT * FROM users; +SELECT * FROM tokens; +SELECT * FROM user_courses; +SELECT * FROM course_teachers; +SELECT * FROM course_roster; diff --git a/backend/remote/production/api.Dockerfile b/backend/remote/production/api.Dockerfile new file mode 100644 index 0000000..903248e --- /dev/null +++ b/backend/remote/production/api.Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM golang:latest AS builder + +WORKDIR /app + +COPY go.mod . +COPY go.sum . +RUN go mod download + +COPY .. . + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main . + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +COPY --from=builder /app/main . + +CMD ["./main"] diff --git a/backend/remote/production/compose.yaml b/backend/remote/production/compose.yaml new file mode 100644 index 0000000..20a63b8 --- /dev/null +++ b/backend/remote/production/compose.yaml @@ -0,0 +1,46 @@ +name: darkspace-prod + +services: + database: + build: + context: .. + dockerfile: ../db.Dockerfile + container_name: db-postgres + restart: always + environment: + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + ports: + - ${DB_PORT}:5432 + volumes: + - db-data:/data/postgres + networks: + - dksp + +networks: + dksp: + driver: bridge + +volumes: + db-data: + + + # api: + # build: + # context: . + # dockerfile: api.db.Dockerfile + # ports: + # - 6789:6789 + # environment: + # - DB_HOST=${DB_HOST} + # - DB_PORT=${DB_PORT} + # - DB_USER=${DB_USERNAME} + # - DB_PASSWORD=${DB_PASSWORD} + # - DB_NAME=${DB_NAME} + # env_file: + # - ../.env + # depends_on: + # - database + # networks: + # - dksp diff --git a/backend/remote/production/prod-init.sql b/backend/remote/production/prod-init.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/remote/test/compose.yaml b/backend/remote/test/compose.yaml new file mode 100644 index 0000000..089b4b7 --- /dev/null +++ b/backend/remote/test/compose.yaml @@ -0,0 +1,25 @@ +name: darkspace-test + +services: + database: + build: + context: .. + dockerfile: ./db.Dockerfile + container_name: db-postgres + environment: + - POSTGRES_USER=${DB_USERNAME} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME} + ports: + - ${DB_PORT}:5432 + volumes: + - db-data:/data/postgres + networks: + - dksp + +networks: + dksp: + driver: bridge + +volumes: + db-data: diff --git a/backend/remote/test/db.Dockerfile b/backend/remote/test/db.Dockerfile new file mode 100644 index 0000000..1c57fdd --- /dev/null +++ b/backend/remote/test/db.Dockerfile @@ -0,0 +1,6 @@ +FROM postgres:16 +LABEL authors="Neo" + +# Uses dev-init.sql to initialize the database for testing, +# with appropriate tables and data. +COPY test-init.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/backend/remote/test/test-init.sql b/backend/remote/test/test-init.sql new file mode 100644 index 0000000..75bfe56 --- /dev/null +++ b/backend/remote/test/test-init.sql @@ -0,0 +1,234 @@ +-- Testing Environment SQL File + +-- Initial setup +CREATE EXTENSION IF NOT EXISTS "citext"; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Users Table +CREATE TABLE IF NOT EXISTS users ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + net_id VARCHAR UNIQUE, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + deleted_at TIMESTAMP WITHOUT TIME ZONE, + full_name VARCHAR, + profile_picture_id UUID, + bio TEXT, + username VARCHAR NOT NULL, + password VARCHAR NOT NULL, + email VARCHAR NOT NULL, + membership INT NOT NULL +); + +-- Courses Table +CREATE TABLE IF NOT EXISTS courses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + archived BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Media Table +CREATE TABLE IF NOT EXISTS media ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR NOT NULL, + url VARCHAR NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Projects Table +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR NOT NULL, + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE NOT NULL +); + +-- Messages Table +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + date TIMESTAMP WITHOUT TIME ZONE, + type BOOLEAN +); + +-- Assignments Table +CREATE TABLE IF NOT EXISTS assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + title VARCHAR NOT NULL, + description TEXT, + date TIMESTAMP WITHOUT TIME ZONE, + due_date TIMESTAMP WITHOUT TIME ZONE +); + +-- Submissions Table +CREATE TABLE IF NOT EXISTS submissions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + file_type VARCHAR, + submission_time TIMESTAMP WITHOUT TIME ZONE, + on_time BOOLEAN, + grade INT, + feedback VARCHAR +); + +-- Authentication Table +CREATE TABLE IF NOT EXISTS tokens ( + hash bytea PRIMARY KEY, + net_id VARCHAR UNIQUE REFERENCES users(net_id) ON DELETE CASCADE, + expiry timestamp(0) with time zone NOT NULL, + scope text NOT NULL +); + +----------------- +--- JUNCTIONS --- +----------------- + +-- Junction Table for Users and Courses (Many-to-Many) +CREATE TABLE IF NOT EXISTS user_courses ( + user_net_id VARCHAR REFERENCES users(net_id) ON DELETE CASCADE, + course_id UUID REFERENCES courses(id) ON DELETE CASCADE , + PRIMARY KEY (user_net_id, course_id) +); + +-- Junction Table for Courses and Messages (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_messages ( + course_id UUID REFERENCES courses(id) ON DELETE CASCADE, + message_id UUID REFERENCES messages(id) ON DELETE CASCADE , + PRIMARY KEY (course_id, message_id) +); + +-- Junction Table for Courses and Teachers (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_teachers ( + course_id UUID REFERENCES courses(id) ON DELETE CASCADE, + teacher_id VARCHAR REFERENCES users(net_id) ON DELETE CASCADE, + PRIMARY KEY (course_id, teacher_id) +); + +-- Junction Table for Courses and Roster (Students) (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_roster ( + course_id UUID REFERENCES courses(id) ON DELETE CASCADE, + student_id VARCHAR REFERENCES users(net_id) ON DELETE CASCADE, + PRIMARY KEY (course_id, student_id) +); + +-- Junction Table for Courses and Assignments (Many-to-Many) +CREATE TABLE IF NOT EXISTS course_assignments ( + course_id UUID REFERENCES courses(id) ON DELETE CASCADE, + assignment_id UUID REFERENCES assignments(id) ON DELETE CASCADE, + PRIMARY KEY (course_id, assignment_id) +); + +-- Junction Table for Assignments and Submissions (Many-to-Many) +CREATE TABLE IF NOT EXISTS assignment_submissions ( + assignment_id UUID REFERENCES assignments(id) ON DELETE CASCADE, + submission_id UUID REFERENCES submissions(id) ON DELETE CASCADE, + PRIMARY KEY (assignment_id, submission_id) +); + +-- Junction Table for Messages and Media (Many-to-Many) +CREATE TABLE IF NOT EXISTS message_media ( + message_id UUID REFERENCES messages(id) ON DELETE CASCADE, + media_id UUID REFERENCES media(id) ON DELETE CASCADE, + PRIMARY KEY (message_id, media_id) +); + + +-- Adding foreign key constraints after all tables are established and maintain direct single relationships +-- Use a cascade deletion. +ALTER TABLE projects ADD COLUMN user_net_id VARCHAR REFERENCES users(net_id) ON DELETE CASCADE; +ALTER TABLE assignments ADD COLUMN media_id UUID REFERENCES media(id) ON DELETE SET NULL; +ALTER TABLE assignments ADD COLUMN course_id UUID REFERENCES courses(id) ON DELETE SET NULL; +ALTER TABLE assignments ADD COLUMN owner_id INT REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE submissions ADD COLUMN user_id INT REFERENCES users(id) ON DELETE CASCADE; + +-- Foreign key for profile picture which relates to the Media table +ALTER TABLE users ADD CONSTRAINT fk_profile_picture + FOREIGN KEY (profile_picture_id) + REFERENCES media (id) + ON DELETE SET NULL; + +-- Insert dummy users +-- Note: Insert users before courses since courses might reference users' net_id if needed +INSERT INTO users (net_id, created_at, updated_at, username, password, email, membership, full_name) VALUES + ('abc123', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'jcena', 'password123', 'abc123@nyu.edu', 0, 'John Cena'), + ('xyz789', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'mmiller', 'mypass789', 'xyz789@example.com', 1, 'Mike Miller'), + ('def456', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'ajackson', 'pass456', 'def456@example.com', 0, 'Alice Jackson'), + ('uvw321', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'ksmith', 'pass321', 'uvw321@example.com', 1, 'Kevin Smith'), + ('ghi987', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'jdoe', 'mysecretpass', 'ghi987@example.com', 0, 'Jane Doe'); + +-- Insert dummy courses +-- Removed the net_id column since it's now intended to be managed through a junction table or direct reference in projects, not stored directly in courses +INSERT INTO courses (title, description, created_at, updated_at) VALUES + ('Introduction to Computer Science', 'Basic concepts of computer programming', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('Advanced Mathematics', 'In-depth coverage of calculus and linear algebra', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('Modern Art History', 'Exploration of art from the 19th century to present', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('Environmental Science', 'Study of climate change and environmental impact', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('Business Management', 'Principles and practices in managing modern businesses', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- Courses with preset ID for test operations. +INSERT INTO courses(id, title, description, created_at, updated_at) VALUES + ('c3b34a9f-8f59-4818-a684-9cda56f42d02', 'Clown Foundations', 'Learn how to be a clown', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('98e64e88-b989-49a0-bbfd-76e158bac634', 'Delete This Course', 'In testing, this course should be deleted', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + + +-- Insert dummy assignments +-- Using the new structure without course_id in the initial insert. Instead, use the junction table to link courses and assignments if needed +INSERT INTO assignments (title, description, date, due_date) VALUES + ('Quiz 1', 'Quiz on basic programming concepts', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + interval '7 days'), + ('Calculus Exam', 'Midterm exam on calculus topics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + interval '14 days'), + ('Art Essay', 'Essay on modern art movements', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + interval '10 days'), + ('Climate Report', 'Group report on climate change effects', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + interval '20 days'), + ('Management Case Study', 'Analysis of a business case study', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + interval '12 days'); + +-- Insert dummy messages +-- Updated to avoid using ARRAY and using the junction table instead +INSERT INTO messages (title, description, date, type) VALUES + ('Welcome!', 'Welcome to the course on Computer Science', CURRENT_TIMESTAMP, TRUE), + ('Assignment Reminder', 'Remember to submit the calculus exam by Friday', CURRENT_TIMESTAMP, FALSE), + ('Field Trip', 'Field trip to modern art museum next week', CURRENT_TIMESTAMP, TRUE), + ('Guest Lecture', 'Upcoming guest lecture on renewable energy', CURRENT_TIMESTAMP, TRUE), + ('Project Groups', 'Project groups for the case study have been assigned', CURRENT_TIMESTAMP, FALSE); + +-- Insert dummy projects +-- Note: Assuming that user_net_id refers to a single user managing the project +INSERT INTO projects (name, description, created_at, updated_at, user_net_id) VALUES + ('Database Design', 'Project focusing on designing efficient databases', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'abc123'), + ('Statistics Software', 'Develop statistical software using Python', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'xyz789'), + ('Art Exhibition', 'Organize a virtual art exhibition', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'def456'), + ('Water Quality', 'Study on water quality in urban areas', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'uvw321'), + ('Startup Plan', 'Create a business plan for a new startup', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'ghi987'); + +-- Insert dummy submissions +-- Note: Assuming each submission is linked to a single user +INSERT INTO submissions (user_id, file_type, submission_time, on_time, grade, feedback) VALUES + ((SELECT id FROM users WHERE username = 'jcena'), 'PDF', CURRENT_TIMESTAMP, TRUE, 85, 'Good effort, but watch out for syntax errors'), + ((SELECT id FROM users WHERE username = 'mmiller'), 'DOCX', CURRENT_TIMESTAMP, FALSE, 78, 'Late submission, but well-written content'), + ((SELECT id FROM users WHERE username = 'ajackson'), 'PDF', CURRENT_TIMESTAMP, TRUE, 92, 'Excellent analysis and creativity'), + ((SELECT id FROM users WHERE username = 'ksmith'), 'ZIP', CURRENT_TIMESTAMP, TRUE, 88, 'Good collaboration, impressive research'), + ((SELECT id FROM users WHERE username = 'jdoe'), 'PDF', CURRENT_TIMESTAMP, TRUE, 90, 'Very thorough and well-structured report'); + +-- Further junction table entries to link data as per new structure need to be added here, for example linking courses to users, messages to courses, etc. +CREATE OR REPLACE FUNCTION sync_user_courses() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO user_courses (user_net_id, course_id) + VALUES (NEW.teacher_id, NEW.course_id); + RETURN NEW; +END; +CREATE TRIGGER course_teachers_after_insert_trigger +AFTER INSERT ON course_teachers +FOR EACH ROW +EXECUTE FUNCTION sync_user_courses(); + +SELECT * FROM courses; +SELECT * FROM assignments; +SELECT * FROM messages; +SELECT * FROM projects; +SELECT * FROM submissions; +SELECT * FROM users; +SELECT * FROM tokens; diff --git a/backend/resources/grade-offline-template.xlsx b/backend/resources/grade-offline-template.xlsx new file mode 100644 index 0000000..7944a58 Binary files /dev/null and b/backend/resources/grade-offline-template.xlsx differ diff --git a/frontend/.yarnrc.yml b/frontend/.yarnrc.yml index ed8bd7f..3186f3f 100644 --- a/frontend/.yarnrc.yml +++ b/frontend/.yarnrc.yml @@ -1,2 +1 @@ -# Fix for GitHub Issue #3 nodeLinker: node-modules diff --git a/frontend/__tests__/page.test.jsx b/frontend/__tests__/page.test.jsx new file mode 100644 index 0000000..dceab70 --- /dev/null +++ b/frontend/__tests__/page.test.jsx @@ -0,0 +1,59 @@ +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import Page from "@/app/(auth)/signup/page"; + +jest.mock("@/lib/helpers/passwordValidator", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +describe("Page component", () => { + test("renders correctly", () => { + const { getByText, getByPlaceholderText } = render(); + + // Assert that necessary elements are present + expect(getByText("Sign up")).toBeInTheDocument(); + expect(getByPlaceholderText("abc123")).toBeInTheDocument(); + expect(getByPlaceholderText("abc123@nyu.edu")).toBeInTheDocument(); + expect(getByPlaceholderText("Enter password")).toBeInTheDocument(); + expect(getByPlaceholderText("Re-enter password")).toBeInTheDocument(); + expect(getByText("Already have an account?")).toBeInTheDocument(); + expect(getByText("Log in")).toBeInTheDocument(); + }); + + test("validates password correctly", async () => { + const { getByTestId, getByPlaceholderText } = render(); + + // Enter invalid password + fireEvent.change(getByPlaceholderText("Enter password"), { + target: { value: "weakpassword" }, + }); + fireEvent.change(getByPlaceholderText("Re-enter password"), { + target: { value: "weakpassword" }, + }); + fireEvent.click(getByTestId("submitButton")); + + // Assert that passwords is invalid error is displayed + await waitFor(() => + expect(getByTestId("errorMessage")).toBeInTheDocument() + ); + }); + + test("validates re-entered password correctly", async () => { + const { getByTestId, getByPlaceholderText } = render(); + + // Enter valid password but different re-entered password + fireEvent.change(getByPlaceholderText("Enter password"), { + target: { value: "StrongPassword123!" }, + }); + fireEvent.change(getByPlaceholderText("Re-enter password"), { + target: { value: "DifferentPassword123!" }, + }); + fireEvent.click(getByTestId("submitButton")); + + // Assert that passwords do not match error is displayed + await waitFor(() => + expect(getByTestId("errorMessage")).toBeInTheDocument() + ); + }); +}); diff --git a/frontend/__tests__/passwordValidator.test.js b/frontend/__tests__/passwordValidator.test.js new file mode 100644 index 0000000..e39172d --- /dev/null +++ b/frontend/__tests__/passwordValidator.test.js @@ -0,0 +1,34 @@ +import "@testing-library/jest-dom"; +import validatePassword from "@/lib/helpers/passwordValidator"; + +describe("validatePassword", () => { + test("returns true for a valid password", () => { + const validPassword = "!aklklaskdlALSKFJ399davklmasd"; + expect(validatePassword(validPassword)).toBe(true); + }); + + test("returns false for a password without a letter", () => { + const invalidPassword = "1234567890!@#"; + expect(validatePassword(invalidPassword)).toBe(false); + }); + + test("returns false for a password without a number", () => { + const invalidPassword = "AbCdEfGhIjKl!@#"; + expect(validatePassword(invalidPassword)).toBe(false); + }); + + test("returns false for a password without a special character", () => { + const invalidPassword = "AbCdEfGhIjKl1234567890"; + expect(validatePassword(invalidPassword)).toBe(false); + }); + + test("returns false for a password shorter than 8 characters", () => { + const invalidPassword = "Ab1!"; + expect(validatePassword(invalidPassword)).toBe(false); + }); + + test("returns false for an empty password", () => { + const emptyPassword = ""; + expect(validatePassword(emptyPassword)).toBe(false); + }); +}); diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 0000000..16eafe3 --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,205 @@ +/** + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +import type { Config } from "jest"; +import nextJest from "next/jest.js"; + +const createJestConfig = nextJest({ + // Provide the path to your Next.js app to load next.config.js and .env files in your test environment + dir: "./", +}); + +const config: Config = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/22/0qjtyz_56d132v_7ptvwy0r40000gn/T/jest_dx", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: "v8", + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^@/(.*)$": "/src/$1", + "^@/components/(.*)$": "/src/components/$1", + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + setupFilesAfterEnv: ["/jest.setup.ts"], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "jest-environment-jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: ["/node_modules/", "\\.pnp\\.[^\\/]+$"], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; + +export default createJestConfig(config); diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts new file mode 100644 index 0000000..d0de870 --- /dev/null +++ b/frontend/jest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom"; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 4678774..6a4fdae 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,4 +1,50 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + async headers() { + return [ + { + // matching all API routes + source: "/api/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, // replace this your actual origin + { + key: "Access-Control-Allow-Methods", + value: "GET,DELETE,PATCH,POST,PUT", + }, + { + key: "Access-Control-Allow-Headers", + value: + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", + }, + ], + source: "/v1/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, // replace this your actual origin + { + key: "Access-Control-Allow-Methods", + value: "GET,DELETE,PATCH,POST,PUT", + }, + { + key: "Access-Control-Allow-Headers", + value: + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", + }, + ], + }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: "http", + hostname: "localhost", + port: "6789", + pathname: "*/**", + }, + ], + }, +}; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index f77dd0c..c49ad2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,26 +8,34 @@ "start": "next start", "lint": "next lint", "format": "prettier --check ./src", - "format:fix": "prettier --write --list-different ./src" + "format:fix": "prettier --write --list-different ./src", + "test": "jest", + "test:watch": "jest --watch" }, "dependencies": { "@sanity/icons": "^2.11.2", - "next": "14.1.0", - "react": "^18", - "react-dom": "^18" + "axios": "^1.6.8", + "next": "^14.2.3", + "react": "^18.3.0", + "react-dom": "^18.3.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", "eslint": "^8", - "eslint-config-next": "14.1.0", + "eslint-config-next": "^14.2.3", "eslint-config-prettier": "^9.1.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "postcss": "^8", "prettier": "^3.2.5", "tailwindcss": "^3.3.0", + "ts-node": "^10.9.2", "typescript": "^5" }, - "packageManager": "yarn@4.1.1+sha256.f3cc0eda8e5560e529c7147565b30faa43b4e472d90e8634d7134a37c7f59781" + "packageManager": "yarn@4.1.1" } diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx index 96214c5..873349f 100644 --- a/frontend/src/app/(auth)/login/page.tsx +++ b/frontend/src/app/(auth)/login/page.tsx @@ -4,7 +4,6 @@ import React, { useState } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; -import { userAgentFromString } from "next/server"; export default function Page() { // const [isBlurred, setIsBlurred] = useState(false); @@ -14,30 +13,38 @@ export default function Page() { // }; const [userLogin, setUserLogin] = useState({ - email: "", + netid: "", password: "", }); const [loginError, setLoginError] = useState(""); const route = useRouter(); - const fetchUserInfo = async () => { + const loginUser = async () => { try { - const res: Response = await fetch("/v1/user/login", { + const res: Response = await fetch("http://localhost:6789/v1/user/login", { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(userLogin), + // headers: { + // "Content-Type": "application/json", + // "Access-Control-Allow-Origin": "*", + // "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + // "Access-Control-Allow-Headers": "Content-Type, Authorization", + // }, + body: JSON.stringify({ + netid: userLogin.netid, + password: userLogin.password, + }), }); if (res.ok) { - const userInfo = await res.json(); - return userInfo; + const data = await res.json(); + localStorage.setItem("token", data.authentication_token.token); + route.push("/"); + console.log("token: %s", data.authentication_token.token); } else { - console.error("Failed to fetch user info:", res.statusText); + console.error("Failed to login user:", res.statusText); return []; } } catch (error) { - console.error("Error fetching user info:", error); + console.error("Error fetching logging user in:", error); return []; } }; @@ -52,22 +59,13 @@ export default function Page() { const handleSubmit = async (e: { preventDefault: () => void }) => { e.preventDefault(); - const info = await fetchUserInfo(); - for (let i = 0; i < info.length; i++) { - if (info[i].email === userLogin.email) { - if (info[i].password === userLogin.password) { - route.push(""); - } else { - setLoginError("Wrong password entered"); - } - } - } - setLoginError("Wrong email entered"); + const info = await loginUser(); + route.push(""); }; return (
-
+
- ) => void; + errorMessage?: string; +} + +const FormInput: React.FC = ({ + label, + type, + name, + placeholder, + value, + onChange, + errorMessage, +}) => { + return ( +
+ + + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ ); +}; + +export default FormInput; diff --git a/frontend/src/app/(auth)/signup/page.tsx b/frontend/src/app/(auth)/signup/page.tsx index 5e0ba23..4892241 100644 --- a/frontend/src/app/(auth)/signup/page.tsx +++ b/frontend/src/app/(auth)/signup/page.tsx @@ -1,72 +1,33 @@ "use client"; -import React, { useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import React, { useState } from "react"; import validatePassword from "@/lib/helpers/passwordValidator"; +import FormInput from "./FormInput"; +import { useRouter } from "next/navigation"; -export default function Page() { - // const [isBlurred, setIsBlurred] = useState(false); - - // const handleFormClick = (): void => { - // setIsBlurred(true); - // }; - - // const [password, setPassword] = useState(""); - const [reenteredPassword, setReenteredPassword] = useState(""); - const [passwordError, setPasswordError] = useState(""); - const [reenteredPasswordError, setReenteredPasswordError] = - useState(""); +const SignUpForm = () => { const [userData, setUserData] = useState({ email: "", + fullname: "", password: "", netid: "", + membership: 0, }); + const [passwordError, setPasswordError] = useState(""); + // const [reenteredPassword, setReenteredPassword] = useState(""); + // const [reenteredPasswordError, setReenteredPasswordError] = useState(""); - // const handlePasswordChange = ( - // e: React.ChangeEvent - // ): void => { - // const newPassword = e.target.value; - // setPassword(newPassword); - // setPasswordError(""); - // if (passwordError) setPasswordError(""); - // }; - - // const handleReenteredPasswordChange = ( - // e: React.ChangeEvent - // ): void => { - // const newReenteredPassword = e.target.value; - // setReenteredPassword(newReenteredPassword); - // setReenteredPasswordError(""); - // if (reenteredPasswordError) setReenteredPasswordError(""); - // }; + const router = useRouter(); - const postNewUser = async (userData: any) => { - try { - const res: Response = await fetch("/v1/user/login", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(userData), - }); - if (res.ok) { - const newUser = await res.json(); - newUser.email = userData.email; - newUser.password = userData.password; - newUser.netid = userData.netid; - } else { - console.error("Failed to create user:", res.statusText); - } - } catch (error) { - console.error("Error creating user:", error); - } - }; - - const handleSubmit = (e: React.FormEvent): void => { + const handleSubmit = (e: { preventDefault: () => void }) => { e.preventDefault(); const isValidPassword = validatePassword(userData.password); - const doPasswordsMatch = userData.password === reenteredPassword; + // const doPasswordsMatch = userData.password === reenteredPassword; + + // console.log(userData.password); + // console.log(reenteredPassword); if (!isValidPassword) { setPasswordError( @@ -75,12 +36,12 @@ export default function Page() { return; } - if (!doPasswordsMatch) { - setReenteredPasswordError("Passwords do not match."); - return; - } + // if (!doPasswordsMatch) { + // setReenteredPasswordError("Passwords do not match."); + // return; + // } + // Call the function to post new user data postNewUser(userData); - console.log("Form submitted"); }; const handleChange = (e: { target: { name: any; value: any } }) => { @@ -90,13 +51,38 @@ export default function Page() { [name]: value, }); setPasswordError(""); - setReenteredPassword(value); - setReenteredPasswordError(""); + // setReenteredPassword(value); + // setReenteredPasswordError(""); + }; + + const postNewUser = async (userData: any) => { + try { + const res: Response = await fetch( + "http://localhost:6789/v1/user/create", + { + method: "POST", + body: JSON.stringify({ + email: userData.email, + fullname: userData.fullname, + password: userData.password, + netid: userData.netid, + membership: userData.membership, + }), + } + ); + if (res.status !== 400) { + router.push("/login"); + } else { + console.error("Failed to create user:", res.statusText); + } + } catch (error) { + console.error("Error creating user:", error); + } }; return (
-
+
- - - - + - - + Membership: +
+ + +
+
+ - {passwordError && ( -

{passwordError}

- )} - - - {reenteredPasswordError && ( -

{reenteredPasswordError}

- )} + {/* }; + }) => setReenteredPassword(e.target.value)} + errorMessage={reenteredPasswordError} + />*/}

@@ -214,4 +200,6 @@ export default function Page() { >

); -} +}; + +export default SignUpForm; diff --git a/frontend/src/app/actions.ts b/frontend/src/app/actions.ts new file mode 100644 index 0000000..a48f621 --- /dev/null +++ b/frontend/src/app/actions.ts @@ -0,0 +1,20 @@ +"use server"; + +import { redirect } from "next/navigation"; + +// Adapted from: +// https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#redirecting +export async function createCourse(id: string) { + try { + // ... + } catch (error) { + // ... + } + + // revalidateTag("posts"); // Update cached posts + redirect(`/post/${id}`); // Navigate to the new post page +} + +export async function navigate(data: FormData) { + redirect(`/posts/${data.get("id")}`); +} diff --git a/frontend/src/app/course/[id]/AddStudent.tsx b/frontend/src/app/course/[id]/AddStudent.tsx new file mode 100644 index 0000000..9ef9aae --- /dev/null +++ b/frontend/src/app/course/[id]/AddStudent.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useState } from "react"; +import addStudentToClass from "./actions"; +import CloseButton from "@/components/buttons/CloseButton"; + +interface AddStudentProps { + onClose: () => void; + courseId: string; +} + +const AddStudent: React.FC = (props) => { + const perms = localStorage.getItem("permissions"); + const [netId, setNetId] = useState(""); + + const postNewStudent = async (studentid: string) => { + try { + const res: Response = await fetch( + "http://localhost:6789/v1/course/addstudent", + { + method: "POST", + body: JSON.stringify({ + netid: studentid, + courseid: props.courseId, + }), + } + ); + if (res.ok) { + const response = await res.json(); + console.log(response); + } else { + console.error("Failed to add student to the course:", res.statusText); + } + } catch (error) { + console.error("Error adding student to the course:", error); + } + }; + + const handleAddStudent = (e: { preventDefault: () => void }) => { + e.preventDefault(); + console.log("submitted student netid"); + postNewStudent(netId); + setNetId(""); + props.onClose(); + }; + + return ( +
+
+ +
+

Add Student

+
+ + setNetId(e.target.value)} + className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md h-8" + required + /> +
+ +
+
+
+ ); +}; + +export default AddStudent; diff --git a/frontend/src/app/course/[id]/actions.ts b/frontend/src/app/course/[id]/actions.ts new file mode 100644 index 0000000..b7a0957 --- /dev/null +++ b/frontend/src/app/course/[id]/actions.ts @@ -0,0 +1,27 @@ +"use server"; + +export default async function addStudentToClass(courseId: string) { + return async (formData: FormData) => { + try { + const res: Response = await fetch( + "http://localhost:6789/v1/course/addstudent", + { + method: "POST", + body: JSON.stringify({ + netid: formData.get("netid") as string, + courseid: courseId, + }), + } + ); + if (res.ok) { + const response = await res.json(); + console.log(response); + } else { + console.error("Failed to add student to the course:", res.statusText); + } + } catch (error) { + console.error("Error adding student to the course:", error); + } + return "form submission success"; + }; +} diff --git a/frontend/src/app/course/[id]/assignments/[assignmentId]/page.tsx b/frontend/src/app/course/[id]/assignments/[assignmentId]/page.tsx new file mode 100644 index 0000000..080004f --- /dev/null +++ b/frontend/src/app/course/[id]/assignments/[assignmentId]/page.tsx @@ -0,0 +1,535 @@ +"use client"; +import { Assignment, Submission, User } from "@/lib/types"; +import { useEffect, useState } from "react"; +import InfoBadge from "@/components/badge/InfoBadge"; +import formattedDate from "@/lib/helpers/formattedDate"; +import { useRouter } from "next/navigation"; + +export default function Page({ params }: { params: { assignmentId: string } }) { + const [viewAssignment, setViewAssignment] = useState({ + created_at: "", + deleted_at: "", + description: "", + due_date: "", + id: "", + title: "", + updated_at: "", + }); + const [token, setIsToken] = useState(""); + const [studentSubmission, setStudentSubmission] = useState(); + const [roster, setRoster] = useState([]); + const [courseId, setCourseId] = useState(""); + const [isTeacher, setIsTeacher] = useState(false); + const [submittedFile, setSubmittedFile] = useState(false); + const [grade, setGrade] = useState(0); + const [feedback, setFeedback] = useState(""); + const [submissionId, setSubmissionId] = useState(""); + const [uploadedExcel, setUploadedExcel] = useState(); + const [studentsSubmission, setStudentsSubmission] = useState(); + const [successMessage, setSuccessMessage] = useState(""); + const [message, setMessage] = useState(""); + const [selectedStudent, setSelectedStudent] = useState(""); + const router = useRouter(); + + useEffect(() => { + const urlPath = window.location.pathname; + const pathParts = urlPath.split("/"); + const courseIdIndex = pathParts.indexOf("course") + 1; + const courseId = pathParts[courseIdIndex]; + const permissions = localStorage.getItem("permissions"); + + if (permissions === "1") { + setIsTeacher(true); + } + setCourseId(courseId); + }, []); + + useEffect(() => { + const t = localStorage.getItem("token"); + if (t) { + setIsToken(t); + } + + const fetchAssignment = async (): Promise => { + const response = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/read`, + { + method: "POST", + body: JSON.stringify({ + assignment_id: params.assignmentId, + token: token, + }), + } + ); + const { assignment }: { assignment: Assignment } = await response.json(); + return assignment; + }; + + const fetchData = async () => { + const path = `http://localhost:6789/v1/course/${courseId}/homepage`; + const response = await fetch(path); + const { roster }: { roster: User[] } = await response.json(); + return { roster }; + }; + + const studentReadSubmission = async () => { + const response = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/${params.assignmentId}/submission/read`, + { + method: "POST", + body: JSON.stringify({ + token: token, + }), + } + ); + const { submission }: { submission: Submission } = await response.json(); + console.log(submission); + return submission; + }; + + fetchData() + .then(({ roster }) => { + setRoster(roster); + }) + .catch(console.error); + + fetchAssignment() + .then((value: Assignment) => { + setViewAssignment(value); + }) + .catch(console.error); + + studentReadSubmission() + .then((value: Submission) => { + setStudentsSubmission(value); + }) + .catch(console.error); + }, [courseId]); + + const downloadSubmission = (selectedStudent: string) => { + const fetchSubmission = async () => { + const response: any = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/${params.assignmentId}/submission/${selectedStudent}/read` + ); + + const { submission } = await response.json(); + setSubmissionId(submission.id); + console.log("RESPONSE: ", submission); + + submission.Media.forEach(async (mediaId: string) => { + try { + const res: any = await fetch( + `http://localhost:6789/v1/course/${courseId}/download/${mediaId}` + ); + const contentDisposition = res.headers.get("Content-Disposition"); + console.log("CONTENT DIS: ", contentDisposition); + let filename = "file"; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + const contentType = + res.headers.get("Content-Type") || "application/octet-stream"; + console.log("CONTENT TYPE: ", contentType); + const blob = await res.blob(); + const link = document.createElement("a"); + + link.href = window.URL.createObjectURL(blob); + + const fileExtension = filename.split(".").pop(); + + link.download = `${filename}.pdf`; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + } catch (error) { + console.error("Error downloading media with ID", mediaId, ":", error); + } + }); + }; + + fetchSubmission() + .then((value) => {}) + .catch(console.error); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files && event.target.files[0]; + if (file) { + setStudentSubmission(file); + } + }; + + const handleUploadButtonClick = async () => { + const subID = await makeSubmission(); + await submitMediaFile(subID); + setGrade(0); + setFeedback(""); + }; + + const makeSubmission = async () => { + try { + const response = await fetch( + `http://localhost:6789/v1/course/assignment/${params.assignmentId}/submission/create`, + { + method: "POST", + body: JSON.stringify({ + token: token, + }), + } + ); + + if (response.ok) { + const { submission } = await response.json(); + return submission.id; + } else { + console.error("Failed to upload file"); + } + } catch (error) { + console.error("Network error:", error); + } + }; + + const submitMediaFile = async (submissionId: string) => { + if (!studentSubmission) return; + const formData = new FormData(); + formData.append("files", studentSubmission); + try { + const response = await fetch( + `http://localhost:6789/v1/course/assignment/submission/${submissionId}/upload`, + { + method: "POST", + body: formData, + } + ); + if (response.ok) { + console.log("Student submission uploaded successfully"); + } else { + console.error("Failed to upload file"); + } + } catch (error) { + console.error("Network error:", error); + } + }; + + const handleGradeChange = (event: React.ChangeEvent) => { + const newGrade = Number(event.target.value); + setGrade(newGrade); + }; + + const handleFeedbackChange = (event: React.ChangeEvent) => { + const newFeedback = event.target.value; + setFeedback(newFeedback); + }; + + const updateSubmission = async () => { + const response = await fetch( + `http://localhost:6789/v1/course/assignment/submission/${submissionId}/update`, + { + method: "POST", + body: JSON.stringify({ + grade: grade, + feedback: feedback, + }), + } + ); + if (response.ok) { + setGrade(0); + setFeedback(""); + console.log("Submission updated successfully"); + } else { + console.error("Failed to update submission"); + } + }; + + const uploadExcel = async (uploadedExcel: any) => { + const formData = new FormData(); + formData.append("files", uploadedExcel); + const response = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/${params.assignmentId}/offline`, + { + method: "POST", + body: formData, + } + ); + if (response.ok) { + setSuccessMessage("Grades successfully updated!"); + } + }; + + const downloadExcel = async () => { + try { + const res: any = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/${params.assignmentId}/offline` + ); + const contentDisposition = res.headers.get("Content-Disposition"); + let filename = "file"; + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match) { + filename = match[1]; + } + } + const contentType = + res.headers.get("Content-Type") || "application/octet-stream"; + const blob = await res.blob(); + const link = document.createElement("a"); + + link.href = window.URL.createObjectURL(blob); + + const fileExtension = filename.split(".").pop(); + + link.download = `${filename}.xlsx`; + + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + } catch (error) { + console.log("Error downloading excel file: ", error); + } + }; + + const handleExcelFileUpload = ( + event: React.ChangeEvent + ) => { + const file = event.target.files && event.target.files[0]; + if (file) { + if ( + file.type === + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) { + setUploadedExcel(file); + } + } + }; + + const handleUploadedExcel = async () => { + uploadExcel(uploadedExcel); + }; + + const handleDeleteAssignment = async () => { + try { + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + headers.append("Authorization", `${token}`); + const response = await fetch( + `http://localhost:6789/v1/course/assignment/${params.assignmentId}/delete`, + { + method: "DELETE", + headers: { + "Access-Control-Request-Method": "POST", + }, + } + ); + if (response.ok) { + router.push(`/course/${courseId}`); + console.log("Assignment deleted successfully"); + } else { + console.error("Failed to delete assignment"); + } + } catch (error) { + console.error("Network error:", error); + } + }; + + return ( + <> +
+
+

+ {viewAssignment.title} +

+
+
+
+
+

Description

+ {viewAssignment.description ? ( +

{viewAssignment.description}

+ ) : ( +

No description provided.

+ )} +
+
+

Due Date

+ +
+ {isTeacher && ( +
+
+

Grading: {selectedStudent}

+ +
+
+

Feedback

+ +
+ + +
+ )} + {!isTeacher && studentsSubmission && ( +
+
+

Grade

+ +
+
+

Feedback

+

{studentsSubmission.feedback}

+
+
+ )} +
+ {message && ( +
+

{message}

+
+ )} + {roster && + !isTeacher && + !message && + !submittedFile && + !studentsSubmission && ( + <> +
+ + + +
+ + )} + {roster && isTeacher && ( + <> +
+ {roster.map((user, i) => ( +
{ + setSelectedStudent(user.full_name); + downloadSubmission(user.id); + }} + > +

{user.full_name}

+

{user.id}

+
+ ))} +
+ {!roster && isTeacher && ( +
+

Students will appear here.

+
+ )} + + )} + {submittedFile && ( +
+

+ Submitted assignment successfully! +

+
+ )} + {isTeacher && ( +
+
+ + +
+ + {successMessage && ( +

{successMessage}

+ )} +
+
+ +
+ )} +
+
+ + ); +} diff --git a/frontend/src/app/course/[id]/layout.tsx b/frontend/src/app/course/[id]/layout.tsx new file mode 100644 index 0000000..fa143bc --- /dev/null +++ b/frontend/src/app/course/[id]/layout.tsx @@ -0,0 +1,14 @@ +import Navbar from "@/components/Navbar"; + +export default function CourseLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/src/app/course/[id]/page.tsx b/frontend/src/app/course/[id]/page.tsx index 6383729..6034fad 100644 --- a/frontend/src/app/course/[id]/page.tsx +++ b/frontend/src/app/course/[id]/page.tsx @@ -1,79 +1,239 @@ +"use client"; + import Announcements from "@/components/coursepage/Announcements"; import Assignments from "@/components/coursepage/Assignments"; -import Discussions from "@/components/Discussions"; -import { Announcement, Assignment, Discussion } from "@/lib/types"; -import Navbar from "@/components/Navbar"; -import apiPath from "@/lib/helpers/apiPath"; +import { Assignment, Course, User } from "@/lib/types"; +import AddStudent from "./AddStudent"; +import { useEffect, useState } from "react"; +import AddButton from "@/components/buttons/AddButton"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; -// These data names must match what the API returns. -interface HomepageData { - name: string; - teacher_name: string; - assignments: Assignment[]; - discussions: Discussion[]; - announcements: Announcement[]; -} +export default function Page({ params }: { params: { id: string } }) { + const [isAddingStudent, setIsAddingStudent] = useState(false); + const [isTeacher, setIsTeacher] = useState(false); + const [data, setData] = useState({ + assignments: [], + roster: [], + id: "", + name: "", + description: "", + professor: "", + banner: "", + created_at: "", + updated_at: "", + deleted_at: "", + }); + const [roster, setRoster] = useState([]); + const [token, setToken] = useState(""); + const router = useRouter(); -// This function is adapted from: -// https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#fetching-data-on-the-server-with-fetch -async function getData(of: string): Promise { - const path = apiPath(`/v1/course/homepage/${of}`); - console.log(path); + useEffect(() => { + const permissions = localStorage.getItem("permissions"); + if (permissions === "1") { + setIsTeacher(true); + } - const res = await fetch(path, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); + const token = localStorage.getItem("token"); + if (token) { + setToken(token); + } - if (!res.ok) { - // This will activate the closest `error.js` Error Boundary - throw new Error("Failed to fetch data"); - } + const fetchData = async () => { + const path = `http://localhost:6789/v1/course/${params.id}/homepage`; + const response = await fetch(path); + const { course, roster }: { course: Course; roster: User[] } = + await response.json(); + return { course, roster }; + }; - return res.json(); -} + fetchData() + .then(({ course, roster }) => { + setData(course); + setRoster(roster); + console.log("SUCCESSFULLY GOT DATA", data); + }) + .catch(console.error); + }, [params.id]); + + const url = params.id; -// Dynamic route example found here: -// https://nextjs.org/docs/app/building-your-application/routing/dynamic-routes#example -export default async function Page({ params }: { params: { slug: string } }) { - const data = await getData(params.slug); + const handleDeleteCourse = async () => { + const headers = new Headers(); + headers.append("Content-Type", "application/json"); + headers.append("Authorization", `${token}`); + headers.append("Access-Control-Request-Method", "POST"); + try { + const response = await fetch( + `http://localhost:6789/v1/course/${params.id}/delete`, + { + method: "DELETE", + headers: headers, + } + ); + if (response.ok) { + router.push("/homepage"); + console.log("Course deleted successfully"); + } else { + console.error("Failed to delete course"); + } + } catch (error) { + console.error("Network error:", error); + } + }; + + const handleDeleteStudent = async (netId: string) => { + try { + const response = await fetch( + `http://localhost:6789/v1/course/${params.id}/${netId}/deletestudent`, + { + method: "DELETE", + headers: { + "Access-Control-Request-Method": "POST", + }, + } + ); + if (response.ok) { + window.location.reload(); + console.log("Student deleted successfully"); + } else { + console.error("Failed to delete student"); + } + } catch (error) { + console.error("Network error:", error); + } + }; return ( -
- -
+ <> +
-
-

- {data.name} -

-

- with, {data.teacher_name} -

+
+ {data && ( + <> +

+ {data.name} +

+

COURSE ID: {data.id}

+ + )}
-
- +
+ + {data.banner && ( +
+ Course Background
+ )} +
+ + {/* ANNOUNCEMENT AND ASSIGNMENTS Section*/} +
+
+ {data && ( +
+

Announcements

+ +
+ )} +
+ +
+ {data && ( +
+

Assignments

+ +
+ )}
-
-
- + + {/* ROSTER AND INSTRUCTOR section */} +
+
+ {data && ( +
+

Roster

+ {isTeacher && ( + { + setIsAddingStudent(true); + }} + /> + )} +
+ {roster ? ( + roster.map((user, i) => ( +
+

{user.full_name}

+

{user.email}

+ {isTeacher && ( + + )} +
+ )) + ) : ( + <> +
+

+ Students will appear here. +

+
+ + )} +
+
+ )}
-
- +
+ {data && ( +
+

Instructors

+
+
+ )}
-
+ + {isAddingStudent && ( + { + setIsAddingStudent(false); + }} + courseId={url} + /> + )} + {isTeacher && ( +
+ +
+ )} + ); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 875c01e..f118f58 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2,32 +2,42 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} +@layer components { + .announcement-item { + @apply flex flex-col w-full p-6 h-full border shadow bg-gray-900 border-gray-700 + } -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} + .assignment-item { + @apply flex flex-col w-full p-3 h-full border shadow bg-gray-900 border-gray-700 + } + + .roster-item { + @apply inline-flex w-full p-2 h-full border shadow bg-gray-900 border-gray-700 align-middle space-x-2 + } -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + .misc-item { + @apply flex flex-col space-y-2 w-full p-4 h-full border shadow bg-gray-900 border-gray-700 + } + + .text-hint { + @apply text-slate-500 italic + } } @layer utilities { - .text-balance { - text-wrap: balance; - } + .text-balance { + text-wrap: balance; + } } + + +html { + scrollbar-face-color: #646464; + scrollbar-base-color: #646464; + scrollbar-3dlight-color: #646464; + scrollbar-highlight-color: #646464; + scrollbar-track-color: #000; + scrollbar-arrow-color: #000; + scrollbar-shadow-color: #646464; + scrollbar-dark-shadow-color: #646464; +} \ No newline at end of file diff --git a/frontend/src/app/homepage/homepage.css b/frontend/src/app/homepage/homepage.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/homepage/page.tsx b/frontend/src/app/homepage/page.tsx new file mode 100644 index 0000000..e9db629 --- /dev/null +++ b/frontend/src/app/homepage/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import Image from "next/image"; +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import CreateCourse from "@/components/homepage/CreateCourse"; +import AddButton from "@/components/buttons/AddButton"; +import { Course } from "@/lib/types"; +import CourseItem from "@/components/homepage/Courses"; + +export default function Home() { + const initCourses: Course[] = []; + const [isCreatingCourse, setIsCreatingCourse] = useState(false); + const [courseArray, setCourseArray] = useState(initCourses); + const [navbarActive, setNavbarActive] = useState(false); + const [isTeacher, setIsTeacher] = useState(false); + const router = useRouter(); + + const currentTerm = "Spring 2024"; + + const handleCreateCourse = (courseData: any) => { + setCourseArray([...courseArray, courseData]); + }; + + useEffect(() => { + const token = localStorage.getItem("token")!; + const permissions = localStorage.getItem("permissions"); + + console.log(token); + if (permissions === "1") { + setIsTeacher(true); + } + const fetchCourses = async (tok: string) => { + const route = "http://localhost:6789/v1/home"; + const res: Response = await fetch(route, { + method: "POST", + body: JSON.stringify({ + token: tok, + }), + }); + + const { courses }: { courses: Course[] } = await res.json(); + setCourseArray(courses); + console.log(courseArray); + }; + + fetchCourses(token).catch(console.error); + }, []); + + const handleIconClick = () => { + setNavbarActive(!navbarActive); + }; + + return ( +
+ +
+ ( +
+

{currentTerm}

+ {isTeacher && ( + { + setIsCreatingCourse(true); + }} + /> + )} +
+ ) +
+ {courseArray.map((course, i) => ( + { + router.push(`/course/${course.id}`); + }} + /> + ))} +
+ {isCreatingCourse && ( + { + setIsCreatingCourse(false); + }} + onCourseCreate={handleCreateCourse} + /> + )} +
+
+ ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index b65a889..bbba656 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -35,7 +35,7 @@ export default function RootLayout({ }>) { return ( - {children} + {children} ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 92a447a..755520c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,144 +1,145 @@ "use client"; -import Image from "next/image"; -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import CreateCourse from "@/components/CreateCourse"; -import AddButton from "@/components/buttons/AddButton"; -import { Course } from "@/lib/types"; -import CourseItem from "@/components/homepage/Courses"; +import Image from "next/image"; +import Link from "next/link"; +import { Token } from "@/lib/types"; -export default function Home() { - const [isCreatingCourse, setIsCreatingCourse] = useState(false); - const [courses, setCourses] = useState([]); - const [navbarActive, setNavbarActive] = useState(false); +export default function Page() { + const [userLogin, setUserLogin] = useState({ + netid: "", + password: "", + }); + const [loginError, setLoginError] = useState(""); const router = useRouter(); + const [token, setToken] = useState(""); + // + // useEffect(() => { + // const t = localStorage.getItem("token"); + // if (t) { + // setToken(t); + // router.push(`/homepage`); + // } + // }, []); - const currentTerm = "Spring 2024"; - - const handleCreateCourse = (courseData: any) => { - setCourses([...courses, courseData]); - }; - - const fetchCourses = async () => { + const loginUser = async () => { try { - const res: Response = await fetch("/v1/course/read", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, + const res: Response = await fetch("http://localhost:6789/v1/user/login", { + method: "POST", + body: JSON.stringify({ + netid: userLogin.netid, + password: userLogin.password, + }), }); - if (res.ok) { - const courses = await res.json(); - return courses; + const data: Token = await res.json(); + localStorage.setItem("token", data.authentication_token.token); + localStorage.setItem("permissions", data.permissions); + router.push(`/homepage`); } else { - console.error("Failed to fetch courses:", res.statusText); + console.error("Failed to login user:", res.statusText); return []; } } catch (error) { - console.error("Error fetching courses:", error); + console.error("Error fetching logging user in:", error); return []; } }; - useEffect(() => { - const getCourses = async () => { - const fetchedCourses = await fetchCourses(); - setCourses(fetchedCourses); - }; - - getCourses(); - }, []); + const handleChange = (e: { target: { name: any; value: any } }) => { + const { name, value } = e.target; + setUserLogin({ + ...userLogin, + [name]: value, + }); + }; - const handleIconClick = () => { - setNavbarActive(!navbarActive); + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + loginUser(); }; return ( -
- -
-
-

{currentTerm}

- { - setIsCreatingCourse(true); - }} +
+
+
+ NYU Logo -
- - {courses.map((course, i) => ( - { - router.push(`/course/${course.id}`); - }} - /> - ))} - - {isCreatingCourse && ( - { - setIsCreatingCourse(false); - }} - onCourseCreate={handleCreateCourse} + Darkspace Logo - )} +
+
+

Log in

+
+ + + + + {loginError &&

{loginError}

} + +
+

+ Don't have an account yet?{" "} + + Sign up + +

+
+
); } diff --git a/frontend/src/components/CreateCourse.tsx b/frontend/src/components/CreateCourse.tsx index 0833ce4..86022d9 100644 --- a/frontend/src/components/CreateCourse.tsx +++ b/frontend/src/components/CreateCourse.tsx @@ -10,10 +10,8 @@ interface props { const CreateCourse: React.FC = (props: props) => { const [courseData, setCourseData] = useState({ - id: "", title: "", professor: "", - location: "", }); const postNewCourse = async (courseData: any) => { @@ -23,14 +21,12 @@ const CreateCourse: React.FC = (props: props) => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify(courseData), + body: JSON.stringify({ + title: courseData.title, + teacherid: courseData.professor + }), }); if (res.ok) { - const newCourse = await res.json(); - newCourse.name = courseData.title; - newCourse.id = courseData.id; - newCourse.teachers.push(courseData.professor); - newCourse.archived = false; } else { console.error("Failed to create course:", res.statusText); } @@ -49,12 +45,7 @@ const CreateCourse: React.FC = (props: props) => { const handleSubmit = (e: { preventDefault: () => void }) => { e.preventDefault(); - const idNum = Date.now().toString(); - setCourseData({ - ...courseData, - id: idNum, - }); - props.onCourseCreate({ ...courseData, id: idNum }); + props.onCourseCreate({ ...courseData }); postNewCourse(courseData); props.onClose(); }; @@ -84,40 +75,6 @@ const CreateCourse: React.FC = (props: props) => { required />
-
- - -
-
- - -
- ) -} +const AddButton: React.FC = ({ + text, + fullWidth, + onClick, +}: ButtonProps) => { + return ( + + ); +}; -export default AddButton; \ No newline at end of file +export default AddButton; diff --git a/frontend/src/components/buttons/SubmitButton.tsx b/frontend/src/components/buttons/SubmitButton.tsx new file mode 100644 index 0000000..0e743f9 --- /dev/null +++ b/frontend/src/components/buttons/SubmitButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +interface props { + text: string; + className: string; +} + +const SubmitButton: React.FC = (props: props) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; + +export default SubmitButton; diff --git a/frontend/src/components/coursepage/AnnouncementDisplay.tsx b/frontend/src/components/coursepage/AnnouncementDisplay.tsx new file mode 100644 index 0000000..e5a40bc --- /dev/null +++ b/frontend/src/components/coursepage/AnnouncementDisplay.tsx @@ -0,0 +1,83 @@ +import { Announcement } from "@/lib/types"; +import InfoBadge from "@/components/badge/InfoBadge"; +import formattedDate from "@/lib/helpers/formattedDate"; +import { useState, useEffect } from "react"; + +interface props { + announcements: Announcement[]; +} + +const AnnouncementDisplay: React.FC = ({ announcements }: props) => { + const [isTeacher, setIsTeacher] = useState(false); + + useEffect(() => { + const permissions = localStorage.getItem("permissions"); + + if (permissions === "1") { + setIsTeacher(true); + } + }); + + const handleDeleteAnnouncement = async (announcementId: string) => { + try { + const response = await fetch( + `http://localhost:6789/v1/course/announcement/${announcementId}/delete`, + { + method: "DELETE", + headers: { + "Access-Control-Request-Method": "POST", + }, + } + ); + if (response.ok) { + window.location.reload(); + console.log("Announcement deleted successfully"); + } else { + console.error("Failed to delete announcement"); + } + } catch (error) { + console.error("Network error:", error); + } + }; + + return ( +
+ {announcements ? ( + announcements.map((announcement: Announcement, i: number) => ( +
+
+

+ {announcement.title} +

+ {isTeacher && ( + + )} +
+ +

+ {announcement.description} +

+
+ )) + ) : ( + <> +
+

New announcements will appear here.

+
+
+
+ + )} +
+ ); +}; + +export default AnnouncementDisplay; diff --git a/frontend/src/components/coursepage/Announcements.tsx b/frontend/src/components/coursepage/Announcements.tsx index f92ec33..a36f726 100644 --- a/frontend/src/components/coursepage/Announcements.tsx +++ b/frontend/src/components/coursepage/Announcements.tsx @@ -3,38 +3,76 @@ import React, { useState, useEffect } from "react"; import CreateAnnouncement from "./CreateAnnouncement"; import AddButton from "@/components/buttons/AddButton"; -import AnnouncementDisplay from "../announcements/AnnouncementDisplay"; +import AnnouncementDisplay from "./AnnouncementDisplay"; +import { useRouter, usePathname } from "next/navigation"; import { Announcement } from "@/lib/types"; interface props { - entries: Announcement[]; + courseId: string; } -const Announcements: React.FC = (props: props) => { - const [announcements, setAnnouncements] = useState([]); - const [isCreatingAnnouncement, setIsCreatingAnnouncement] = useState(false); +const Announcements: React.FC = ({ courseId }: props) => { + const router = useRouter(); + const pathName = usePathname(); - const handleCreateAnnouncement = (announcementData: any) => { - setAnnouncements([...announcements, announcementData]); + // Function is a variation of: https://www.joshwcomeau.com/nextjs/refreshing-server-side-props/ + // and https://nextjs.org/docs/app/api-reference/functions/use-pathname + // and https://github.com/vercel/next.js/discussions/62146 + const refreshData = () => { + router.push(pathName); + window.location.reload(); }; + const [isCreatingAnnouncement, setIsCreatingAnnouncement] = useState(false); + const [isTeacher, setIsTeacher] = useState(false); + const [token, setToken] = useState(""); + + async function fetchAnnouncements() { + const response = await fetch( + `http://localhost:6789/v1/course/${courseId}/announcement/read` + ); + const { announcements } = await response.json(); + setAnnouncements(announcements); + } + + const [announced, setAnnouncements] = useState([]); + + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + setToken(token); + } + + const permissions = localStorage.getItem("permissions"); + if (permissions === "1") { + setIsTeacher(true); + } + + fetchAnnouncements(); + }, []); + return (
-
-

Announcements

+ {isTeacher && ( { setIsCreatingAnnouncement(true); }} /> -
- + )} + + + {isCreatingAnnouncement && ( { setIsCreatingAnnouncement(false); + refreshData(); }} - onAnnouncementCreate={handleCreateAnnouncement} + token={token} + params={{ id: courseId }} /> )}
diff --git a/frontend/src/components/coursepage/AssignmentDisplay.tsx b/frontend/src/components/coursepage/AssignmentDisplay.tsx new file mode 100644 index 0000000..4da5794 --- /dev/null +++ b/frontend/src/components/coursepage/AssignmentDisplay.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import CloseButton from "@/components/buttons/CloseButton"; +import { Assignment } from "@/lib/types"; + +interface props { + assignment: Assignment; + onClose: () => void; +} + +const AssignmentDisplay: React.FC = ({ assignment, onClose }: props) => { + const handleSubmit = (e: { preventDefault: () => void }) => { + e.preventDefault(); + //Submission logic + onClose(); + }; + + const isPastDueDate = + assignment && new Date(assignment.due_date) < new Date(); + + return ( +
+
+ +
+

+ {assignment ? assignment.title : ""} +

+

+ Due Date:{" "} + {assignment + ? new Date(assignment.due_date).toLocaleDateString() + : ""} +

+

+ Description: {assignment ? assignment.description : ""} +

+ {!isPastDueDate ? ( +
+ + {}} + /> +
+ ) : ( +

+ You can no longer submit this assignment. +

+ )} + {!isPastDueDate ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default AssignmentDisplay; diff --git a/frontend/src/components/coursepage/Assignments.tsx b/frontend/src/components/coursepage/Assignments.tsx index 4f9cd9a..1723021 100644 --- a/frontend/src/components/coursepage/Assignments.tsx +++ b/frontend/src/components/coursepage/Assignments.tsx @@ -1,137 +1,164 @@ "use client"; -import React, { useState, useEffect } from "react"; -import CreateAssignment from "./CreateAssignment"; +import React, { useEffect, useState } from "react"; import AddButton from "@/components/buttons/AddButton"; import { Assignment } from "@/lib/types"; +import CreateAssignment from "./CreateAssignment"; +import AssignmentDisplay from "./AssignmentDisplay"; +import InfoBadge from "@/components/badge/InfoBadge"; +import truncateString from "@/lib/helpers/truncateString"; +import Router from "next/client"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import formattedDate from "@/lib/helpers/formattedDate"; interface props { entries: Assignment[]; + courseId: string; } -const Assignments: React.FC = (props: props) => { - const [selectedAssignment, setSelectedAssignment] = useState(""); - const [uploadedFiles, setUploadedFiles] = useState([]); - const [assignments, setAssignments] = useState([]); - const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); +const Assignments: React.FC = ({ entries, courseId }: props) => { + const [selectedAssignment, setSelectedAssignment] = useState({ + id: "", + title: "", + due_date: "", + description: "", + created_at: "", + updated_at: "", + deleted_at: "", + }); - const handleCreateAssignment = (assignmentData: any) => { - setAssignments([...assignments, assignmentData]); - }; + const [assignments, setAssignments] = useState(entries); + /** + * assignmentMap maps assignment object values to their + * name pairs. + */ + const [assignmentMap, setAssignmentMap] = useState>( + new Map() + ); - const handleSelectChange = (event: React.ChangeEvent) => { - setSelectedAssignment(event.target.value); - }; + const [isTeacher, setIsTeacher] = useState(false); + const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); + const [token, setIsToken] = useState(""); + const [isViewingAssignment, setSetIsViewingAssignment] = useState(false); - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - const files = Array.from(event.dataTransfer.files); - setUploadedFiles(files); - }; + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + setIsToken(token); + } - const handleFileInputChange = ( - event: React.ChangeEvent - ) => { - if (event.target.files) { - const files = Array.from(event.target.files); - setUploadedFiles(files); + const permissions = localStorage.getItem("permissions"); + if (permissions === "1") { + setIsTeacher(true); } + + const fetchAssignments = async (): Promise => { + const init: RequestInit = { + headers: { + Authorization: `Bearer ${token}`, + }, + }; + const response = await fetch( + `http://localhost:6789/v1/course/${courseId}/assignment/read` + ); + const { assignments }: { assignments: Assignment[] } = + await response.json(); + setAssignments(assignments); + return assignments; + }; + + fetchAssignments() + .then((a: Assignment[]) => { + if (a) { + // Assign map's keys and values based on fetched assignments. + const newMap: Map = new Map(); + for (let i = 0; i < a.length; i++) { + const el = a[i]; + newMap.set(el.title, el); + } + setAssignmentMap(newMap); + console.log(newMap); + } + }) + .catch(console.error); + }, []); + + const handleSelectChange = (event: React.ChangeEvent) => { + // Get the assignment by its UUID. + const value: Assignment = assignmentMap.get(event.target.value)!; + setSelectedAssignment(value); + setSetIsViewingAssignment(true); }; - const handleFileRemove = (index: number) => { - const newFiles = [...uploadedFiles]; - newFiles.splice(index, 1); - setUploadedFiles(newFiles); + const refreshData = async () => { + setIsCreatingAssignment(false); }; + const router = useRouter(); + return ( -
-
-

Assignments

+
+ {isTeacher && ( { setIsCreatingAssignment(true); }} /> -
- - {selectedAssignment !== "" && - assignments[parseInt(selectedAssignment)] && ( + )} +
+ {assignments ? ( + assignments.map((assignment: Assignment, i: number) => ( + +
+
{assignment.title}
+ + {assignment.description && ( +

+ {truncateString(assignment.description, 20)} +

+ )} +
+ + )) + ) : ( <> -

- {assignments[parseInt(selectedAssignment)].title} -

-

- Due Date: {assignments[parseInt(selectedAssignment)].duedate} -

-

- {assignments[parseInt(selectedAssignment)].description} -

+
+

New assignments will appear here.

+
+
)} -

File Upload

-
event.preventDefault()} - style={{ - border: "2px dashed #aaa", - borderRadius: "5px", - padding: "20px", - marginTop: "20px", - width: "550px", - }} - > -

File Upload

- - -
-
-

Uploaded Files:

-
    - {uploadedFiles.map((file, index) => ( -
  • - {file.name} - {file.size} bytes - -
  • - ))} -
{isCreatingAssignment && ( { - setIsCreatingAssignment(false); + refreshData(); + }} + token={token} + params={{ id: courseId }} + /> + )} + {isViewingAssignment && !isTeacher && ( + { + setSetIsViewingAssignment(false); }} - onCourseCreate={handleCreateAssignment} + assignment={selectedAssignment} /> )} + {/*{isViewingAssignment && isTeacher && (*/} + {/* {*/} + {/* setSetIsViewingAssignment(false);*/} + {/* }}*/} + {/* assignmentid={selectedAssignment}*/} + {/* />*/} + {/*)}*/}
); }; diff --git a/frontend/src/components/coursepage/CreateAnnouncement.tsx b/frontend/src/components/coursepage/CreateAnnouncement.tsx index 387f6d6..ddae12b 100644 --- a/frontend/src/components/coursepage/CreateAnnouncement.tsx +++ b/frontend/src/components/coursepage/CreateAnnouncement.tsx @@ -5,52 +5,21 @@ import CloseButton from "@/components/buttons/CloseButton"; interface props { onClose: () => void; - onAnnouncementCreate: (announcementData: any) => void; + // onAnnouncementCreate: (announcementData: any) => void; + params: { + id: string; + }; + token: string; } -const CreateAnnouncement: React.FC = (props: props) => { - const currentDate = new Date(); - - const formattedDate = `${currentDate - .toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) - .replace(/\//g, "-")} ${currentDate.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - })}`; - - const [announcementData, setAnnouncementData] = useState({ - id: "", +const CreateAnnouncement: React.FC = (props) => { + const initialAnnouncement = { + courseId: props.params.id, + token: props.token, title: "", - date: formattedDate, description: "", - }); - - const postNewAnnouncement = async (announcementData: any) => { - try { - const res: Response = await fetch("/v1/course/announcement/create", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(announcementData), - }); - if (res.ok) { - const newAnnouncement = await res.json(); - newAnnouncement.name = announcementData.title; - newAnnouncement.id = announcementData.id; - newAnnouncement.description = announcementData.description; - newAnnouncement.date = announcementData.date; - } else { - console.error("Failed to create announcement:", res.statusText); - } - } catch (error) { - console.error("Error creating announcement:", error); - } }; + const [announcementData, setAnnouncementData] = useState(initialAnnouncement); const handleChange = (e: { target: { name: any; value: any } }) => { const { name, value } = e.target; @@ -62,13 +31,24 @@ const CreateAnnouncement: React.FC = (props: props) => { const handleSubmit = (e: { preventDefault: () => void }) => { e.preventDefault(); - const idNum = Date.now().toString(); - setAnnouncementData({ - ...announcementData, - id: idNum, - }); - props.onAnnouncementCreate({ ...announcementData, id: idNum }); - postNewAnnouncement(announcementData); + const postNewAnnouncement = async (announcementData: any) => { + const res: Response = await fetch( + `http://localhost:6789/v1/course/${announcementData.courseId}/announcement/create`, + { + method: "POST", + body: JSON.stringify({ + courseid: announcementData.courseid, + token: announcementData.token, + title: announcementData.title, + description: announcementData.description, + media: [], + }), + } + ); + return res; + }; + + postNewAnnouncement(announcementData).catch(console.error); props.onClose(); }; diff --git a/frontend/src/components/coursepage/CreateAssignment.tsx b/frontend/src/components/coursepage/CreateAssignment.tsx index 1037c96..869de53 100644 --- a/frontend/src/components/coursepage/CreateAssignment.tsx +++ b/frontend/src/components/coursepage/CreateAssignment.tsx @@ -5,31 +5,32 @@ import CloseButton from "@/components/buttons/CloseButton"; interface props { onClose: () => void; - onCourseCreate: (assignmentData: any) => void; + token: string; + params: { + id: string; + }; } const CreateAssignment: React.FC = (props: props) => { const [assignmentData, setAssignmentData] = useState({ - id: "", title: "", - dueDate: "", + duedate: "", description: "", + media: [], + token: props.token, + courseid: props.params.id, }); const postNewAssignment = async (assignmentData: any) => { try { - const res: Response = await fetch("/v1/course/assignment/create", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(assignmentData), - }); + const res: Response = await fetch( + "http://localhost:6789/v1/course/assignment/create", + { + method: "POST", + body: JSON.stringify(assignmentData), + } + ); if (res.ok) { - const newAssignment = await res.json(); - newAssignment.name = assignmentData.title; - newAssignment.id = assignmentData.id; - newAssignment.due_date = assignmentData.dueDate; } else { console.error("Failed to create assignment:", res.statusText); } @@ -48,12 +49,6 @@ const CreateAssignment: React.FC = (props: props) => { const handleSubmit = (e: { preventDefault: () => void }) => { e.preventDefault(); - const idNum = Date.now().toString(); - setAssignmentData({ - ...assignmentData, - id: idNum, - }); - props.onCourseCreate({ ...assignmentData, id: idNum }); postNewAssignment(assignmentData); props.onClose(); }; @@ -90,10 +85,10 @@ const CreateAssignment: React.FC = (props: props) => { Due Date: @@ -106,7 +101,7 @@ const CreateAssignment: React.FC = (props: props) => { Description: = (props: props) => { + const url = `http://localhost:6789/v1/course/${props.data.banner}/banner/read`; return (
-
-
+
+
Course Background
-

- {props.data.title} -

-

with {props.data.professor}

-

+

{props.data.name}

+ {/*

with {props.data.professor}

*/} + {/*

{props.data.location} -

+ */}
-
+ {/*
@@ -41,7 +37,7 @@ const CourseItem: React.FC = (props: props) => {
-
+
*/}
); diff --git a/frontend/src/components/homepage/CreateCourse.tsx b/frontend/src/components/homepage/CreateCourse.tsx new file mode 100644 index 0000000..0ee1d0c --- /dev/null +++ b/frontend/src/components/homepage/CreateCourse.tsx @@ -0,0 +1,150 @@ +"use client"; + +import React, { useState } from "react"; +import CloseButton from "@/components/buttons/CloseButton"; +import { Course } from "@/lib/types"; + +interface props { + onClose: () => void; + onCourseCreate: (courseData: any) => void; +} + +const CreateCourse: React.FC = (props: props) => { + const token = localStorage.getItem("token"); + const EmptyImageData = new Uint8Array(0); + const EmptyFile = new File([EmptyImageData], "empty-image.png", { + type: "image/png", + }); + const [courseData, setCourseData] = useState({ + title: "", + token: token, + }); + const [bannerFile, setBannerFile] = useState(EmptyFile); + + const postNewCourse = async (courseData: any) => { + try { + const res: Response = await fetch( + "http://localhost:6789/v1/course/create", + { + method: "POST", + body: JSON.stringify(courseData), + } + ); + console.log(res.ok); + if (res.ok) { + const course_id = await res.json(); + return course_id; + } else { + console.error("Failed to create course:", res.statusText); + } + } catch (error) { + console.error("Error creating course:", error); + } + }; + + const postNewBanner = async (course: any) => { + try { + var data = new FormData(); + data.append("file", bannerFile); + const res: Response = await fetch( + `http://localhost:6789/v1/course/${course.course.id}/banner/create`, + { + method: "POST", + body: data, + } + ); + if (res.ok) { + } else { + console.error("Failed to create course:", res.statusText); + } + } catch (error) { + console.error("Error creating course:", error); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, files, value } = e.target; + if (files && files.length > 0) { + const file = files[0]; + const validTypes = ["image/png", "image/jpeg", "image/jpg"]; + if (validTypes.includes(file.type)) { + setBannerFile(file); + } else { + e.target.value = ""; + alert("Please select a valid image file (PNG or JPG)."); + } + } else { + setCourseData({ + ...courseData, + [name]: value, + }); + } + }; + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + // props.onCourseCreate({ ...courseData }); + try { + const courseInfo = await postNewCourse(courseData); + postNewBanner(courseInfo); + } catch (error) { + console.error("Error creating course:", error); + } + + props.onClose(); + }; + + return ( +
+
+ +
+

+ Create New Course +

+
+ + +
+
+ + +
+ +
+
+
+ ); +}; + +export default CreateCourse; diff --git a/frontend/src/components/DashboardAssignments.tsx b/frontend/src/components/homepage/DashboardAssignments.tsx similarity index 100% rename from frontend/src/components/DashboardAssignments.tsx rename to frontend/src/components/homepage/DashboardAssignments.tsx diff --git a/frontend/src/components/DashboardDiscussions.tsx b/frontend/src/components/homepage/DashboardDiscussions.tsx similarity index 100% rename from frontend/src/components/DashboardDiscussions.tsx rename to frontend/src/components/homepage/DashboardDiscussions.tsx diff --git a/frontend/src/lib/helpers/formattedDate.ts b/frontend/src/lib/helpers/formattedDate.ts index a8495fa..a7b1034 100644 --- a/frontend/src/lib/helpers/formattedDate.ts +++ b/frontend/src/lib/helpers/formattedDate.ts @@ -1,11 +1,10 @@ -const formattedDate = () => { - return new Date() - .toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) - .replace(/\//g, "-"); +const formattedDate = (isoDateString: string) => { + return new Date(isoDateString).toLocaleDateString("en-US", { + day: "2-digit", + month: "long", + year: "numeric", + }); + // .replace(/\//g, "-"); }; export default formattedDate; diff --git a/frontend/src/lib/helpers/truncateString.ts b/frontend/src/lib/helpers/truncateString.ts new file mode 100644 index 0000000..8f6281a --- /dev/null +++ b/frontend/src/lib/helpers/truncateString.ts @@ -0,0 +1,8 @@ +// truncateString slices a string up to a specified length, then adds +// ellipses to the end of the string. +const truncateString = (s: string, end: number) => { + if (s.length <= 50) return s; + return s.slice(0, end) + "..."; +}; + +export default truncateString; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 464ee79..111d852 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,24 +1,52 @@ -export interface Announcement { +export interface Entity { id: string; + created_at: string; + updated_at: string; + deleted_at: string; +} + +export interface Announcement extends Entity { title: string; date: string; description: string; } -export interface Assignment { - id: string; +export interface Assignment extends Entity { title: string; - duedate: string; + due_date: string; description: string; } -export interface Course { - id: string; - title: string; +export interface Course extends Entity { + name: string; + description: string; professor: string; - location: string; + banner: string; + roster: string[]; + assignments?: Assignment[]; } export interface Discussion { - name: string; + title: string; + description: string; +} + +export interface User extends Entity { + username: string; + full_name: string; + email: string; +} + +export interface Token { + authentication_token: { token: string }; + permissions: string; +} + +export interface Submission { + user_id: string; + assignment_id: string; + grade: string; + feedback: string; + file: any; + file_path: string; } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 39a5169..bc9b9f2 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,9 +18,16 @@ } ], "paths": { - "@/*": ["./src/*"] - }, + "@/*": ["./src/*"], + "@/components/*": ["./src/components/*"] + } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "./__tests__/*.jsx" + ], "exclude": ["node_modules"] } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 66c4808..12ebfa6 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.3.2": + version: 4.3.3 + resolution: "@adobe/css-tools@npm:4.3.3" + checksum: 10c0/e76e712df713964b87cdf2aca1f0477f19bebd845484d5fcba726d3ec7782366e2f26ec8cb2dcfaf47081a5c891987d8a9f5c3f30d11e1eb3c1848adc27fcb24 + languageName: node + linkType: hard + "@alloc/quick-lru@npm:^5.2.0": version: 5.2.0 resolution: "@alloc/quick-lru@npm:5.2.0" @@ -19,6 +26,372 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.2.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.1, @babel/code-frame@npm:^7.24.2": + version: 7.24.2 + resolution: "@babel/code-frame@npm:7.24.2" + dependencies: + "@babel/highlight": "npm:^7.24.2" + picocolors: "npm:^1.0.0" + checksum: 10c0/d1d4cba89475ab6aab7a88242e1fd73b15ecb9f30c109b69752956434d10a26a52cbd37727c4eca104b6d45227bd1dfce39a6a6f4a14c9b2f07f871e968cf406 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.23.5": + version: 7.24.4 + resolution: "@babel/compat-data@npm:7.24.4" + checksum: 10c0/9cd8a9cd28a5ca6db5d0e27417d609f95a8762b655e8c9c97fd2de08997043ae99f0139007083c5e607601c6122e8432c85fe391731b19bf26ad458fa0c60dd3 + languageName: node + linkType: hard + +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.9": + version: 7.24.4 + resolution: "@babel/core@npm:7.24.4" + dependencies: + "@ampproject/remapping": "npm:^2.2.0" + "@babel/code-frame": "npm:^7.24.2" + "@babel/generator": "npm:^7.24.4" + "@babel/helper-compilation-targets": "npm:^7.23.6" + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helpers": "npm:^7.24.4" + "@babel/parser": "npm:^7.24.4" + "@babel/template": "npm:^7.24.0" + "@babel/traverse": "npm:^7.24.1" + "@babel/types": "npm:^7.24.0" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/fc136966583e64d6f84f4a676368de6ab4583aa87f867186068655b30ef67f21f8e65a88c6d446a7efd219ad7ffb9185c82e8a90183ee033f6f47b5026641e16 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.24.1, @babel/generator@npm:^7.24.4, @babel/generator@npm:^7.7.2": + version: 7.24.4 + resolution: "@babel/generator@npm:7.24.4" + dependencies: + "@babel/types": "npm:^7.24.0" + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + jsesc: "npm:^2.5.1" + checksum: 10c0/67a1b2f7cc985aaaa11b01e8ddd4fffa4f285837bc7a209738eb8203aa34bdafeb8507ed75fd883ddbabd641a036ca0a8d984e760f28ad4a9d60bff29d0a60bb + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/helper-compilation-targets@npm:7.23.6" + dependencies: + "@babel/compat-data": "npm:^7.23.5" + "@babel/helper-validator-option": "npm:^7.23.5" + browserslist: "npm:^4.22.2" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/ba38506d11185f48b79abf439462ece271d3eead1673dd8814519c8c903c708523428806f05f2ec5efd0c56e4e278698fac967e5a4b5ee842c32415da54bc6fa + languageName: node + linkType: hard + +"@babel/helper-environment-visitor@npm:^7.22.20": + version: 7.22.20 + resolution: "@babel/helper-environment-visitor@npm:7.22.20" + checksum: 10c0/e762c2d8f5d423af89bd7ae9abe35bd4836d2eb401af868a63bbb63220c513c783e25ef001019418560b3fdc6d9a6fb67e6c0b650bcdeb3a2ac44b5c3d2bdd94 + languageName: node + linkType: hard + +"@babel/helper-function-name@npm:^7.23.0": + version: 7.23.0 + resolution: "@babel/helper-function-name@npm:7.23.0" + dependencies: + "@babel/template": "npm:^7.22.15" + "@babel/types": "npm:^7.23.0" + checksum: 10c0/d771dd1f3222b120518176733c52b7cadac1c256ff49b1889dbbe5e3fed81db855b8cc4e40d949c9d3eae0e795e8229c1c8c24c0e83f27cfa6ee3766696c6428 + languageName: node + linkType: hard + +"@babel/helper-hoist-variables@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-hoist-variables@npm:7.22.5" + dependencies: + "@babel/types": "npm:^7.22.5" + checksum: 10c0/60a3077f756a1cd9f14eb89f0037f487d81ede2b7cfe652ea6869cd4ec4c782b0fb1de01b8494b9a2d2050e3d154d7d5ad3be24806790acfb8cbe2073bf1e208 + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.22.15": + version: 7.24.3 + resolution: "@babel/helper-module-imports@npm:7.24.3" + dependencies: + "@babel/types": "npm:^7.24.0" + checksum: 10c0/052c188adcd100f5e8b6ff0c9643ddaabc58b6700d3bbbc26804141ad68375a9f97d9d173658d373d31853019e65f62610239e3295cdd58e573bdcb2fded188d + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/helper-module-transforms@npm:7.23.3" + dependencies: + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-module-imports": "npm:^7.22.15" + "@babel/helper-simple-access": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + "@babel/helper-validator-identifier": "npm:^7.22.20" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/211e1399d0c4993671e8e5c2b25383f08bee40004ace5404ed4065f0e9258cc85d99c1b82fd456c030ce5cfd4d8f310355b54ef35de9924eabfc3dff1331d946 + languageName: node + linkType: hard + +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.24.0, @babel/helper-plugin-utils@npm:^7.8.0": + version: 7.24.0 + resolution: "@babel/helper-plugin-utils@npm:7.24.0" + checksum: 10c0/90f41bd1b4dfe7226b1d33a4bb745844c5c63e400f9e4e8bf9103a7ceddd7d425d65333b564d9daba3cebd105985764d51b4bd4c95822b97c2e3ac1201a8a5da + languageName: node + linkType: hard + +"@babel/helper-simple-access@npm:^7.22.5": + version: 7.22.5 + resolution: "@babel/helper-simple-access@npm:7.22.5" + dependencies: + "@babel/types": "npm:^7.22.5" + checksum: 10c0/f0cf81a30ba3d09a625fd50e5a9069e575c5b6719234e04ee74247057f8104beca89ed03e9217b6e9b0493434cedc18c5ecca4cea6244990836f1f893e140369 + languageName: node + linkType: hard + +"@babel/helper-split-export-declaration@npm:^7.22.6": + version: 7.22.6 + resolution: "@babel/helper-split-export-declaration@npm:7.22.6" + dependencies: + "@babel/types": "npm:^7.22.5" + checksum: 10c0/d83e4b623eaa9622c267d3c83583b72f3aac567dc393dda18e559d79187961cb29ae9c57b2664137fc3d19508370b12ec6a81d28af73a50e0846819cb21c6e44 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.23.4": + version: 7.24.1 + resolution: "@babel/helper-string-parser@npm:7.24.1" + checksum: 10c0/2f9bfcf8d2f9f083785df0501dbab92770111ece2f90d120352fda6dd2a7d47db11b807d111e6f32aa1ba6d763fe2dc6603d153068d672a5d0ad33ca802632b2 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.22.20": + version: 7.22.20 + resolution: "@babel/helper-validator-identifier@npm:7.22.20" + checksum: 10c0/dcad63db345fb110e032de46c3688384b0008a42a4845180ce7cd62b1a9c0507a1bed727c4d1060ed1a03ae57b4d918570259f81724aaac1a5b776056f37504e + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/helper-validator-option@npm:7.23.5" + checksum: 10c0/af45d5c0defb292ba6fd38979e8f13d7da63f9623d8ab9ededc394f67eb45857d2601278d151ae9affb6e03d5d608485806cd45af08b4468a0515cf506510e94 + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.24.4": + version: 7.24.4 + resolution: "@babel/helpers@npm:7.24.4" + dependencies: + "@babel/template": "npm:^7.24.0" + "@babel/traverse": "npm:^7.24.1" + "@babel/types": "npm:^7.24.0" + checksum: 10c0/747ef62b7fe87de31a2f3c19ff337a86cbb79be2f6c18af63133b614ab5a8f6da5b06ae4b06fb0e71271cb6a27efec6f8b6c9f44c60b8a18777832dc7929e6c5 + languageName: node + linkType: hard + +"@babel/highlight@npm:^7.24.2": + version: 7.24.2 + resolution: "@babel/highlight@npm:7.24.2" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.22.20" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/98ce00321daedeed33a4ed9362dc089a70375ff1b3b91228b9f05e6591d387a81a8cba68886e207861b8871efa0bc997ceabdd9c90f6cce3ee1b2f7f941b42db + languageName: node + linkType: hard + +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.0, @babel/parser@npm:^7.24.1, @babel/parser@npm:^7.24.4": + version: 7.24.4 + resolution: "@babel/parser@npm:7.24.4" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/8381e1efead5069cb7ed2abc3a583f4a86289b2f376c75cecc69f59a8eb36df18274b1886cecf2f97a6a0dff5334b27330f58535be9b3e4e26102cc50e12eac8 + languageName: node + linkType: hard + +"@babel/plugin-syntax-async-generators@npm:^7.8.4": + version: 7.8.4 + resolution: "@babel/plugin-syntax-async-generators@npm:7.8.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/d13efb282838481348c71073b6be6245b35d4f2f964a8f71e4174f235009f929ef7613df25f8d2338e2d3e44bc4265a9f8638c6aaa136d7a61fe95985f9725c8 + languageName: node + linkType: hard + +"@babel/plugin-syntax-bigint@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-bigint@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/686891b81af2bc74c39013655da368a480f17dd237bf9fbc32048e5865cb706d5a8f65438030da535b332b1d6b22feba336da8fa931f663b6b34e13147d12dde + languageName: node + linkType: hard + +"@babel/plugin-syntax-class-properties@npm:^7.8.3": + version: 7.12.13 + resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.12.13" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/95168fa186416195280b1264fb18afcdcdcea780b3515537b766cb90de6ce042d42dd6a204a39002f794ae5845b02afb0fd4861a3308a861204a55e68310a120 + languageName: node + linkType: hard + +"@babel/plugin-syntax-import-meta@npm:^7.8.3": + version: 7.10.4 + resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.10.4" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/0b08b5e4c3128523d8e346f8cfc86824f0da2697b1be12d71af50a31aff7a56ceb873ed28779121051475010c28d6146a6bfea8518b150b71eeb4e46190172ee + languageName: node + linkType: hard + +"@babel/plugin-syntax-json-strings@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-json-strings@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/e98f31b2ec406c57757d115aac81d0336e8434101c224edd9a5c93cefa53faf63eacc69f3138960c8b25401315af03df37f68d316c151c4b933136716ed6906e + languageName: node + linkType: hard + +"@babel/plugin-syntax-jsx@npm:^7.7.2": + version: 7.24.1 + resolution: "@babel/plugin-syntax-jsx@npm:7.24.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.24.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/6cec76fbfe6ca81c9345c2904d8d9a8a0df222f9269f0962ed6eb2eb8f3f10c2f15e993d1ef09dbaf97726bf1792b5851cf5bd9a769f966a19448df6be95d19a + languageName: node + linkType: hard + +"@babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": + version: 7.10.4 + resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.10.4" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/2594cfbe29411ad5bc2ad4058de7b2f6a8c5b86eda525a993959438615479e59c012c14aec979e538d60a584a1a799b60d1b8942c3b18468cb9d99b8fd34cd0b + languageName: node + linkType: hard + +"@babel/plugin-syntax-nullish-coalescing-operator@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-nullish-coalescing-operator@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/2024fbb1162899094cfc81152449b12bd0cc7053c6d4bda8ac2852545c87d0a851b1b72ed9560673cbf3ef6248257262c3c04aabf73117215c1b9cc7dd2542ce + languageName: node + linkType: hard + +"@babel/plugin-syntax-numeric-separator@npm:^7.8.3": + version: 7.10.4 + resolution: "@babel/plugin-syntax-numeric-separator@npm:7.10.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.10.4" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/c55a82b3113480942c6aa2fcbe976ff9caa74b7b1109ff4369641dfbc88d1da348aceb3c31b6ed311c84d1e7c479440b961906c735d0ab494f688bf2fd5b9bb9 + languageName: node + linkType: hard + +"@babel/plugin-syntax-object-rest-spread@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-object-rest-spread@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/ee1eab52ea6437e3101a0a7018b0da698545230015fc8ab129d292980ec6dff94d265e9e90070e8ae5fed42f08f1622c14c94552c77bcac784b37f503a82ff26 + languageName: node + linkType: hard + +"@babel/plugin-syntax-optional-catch-binding@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-optional-catch-binding@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/27e2493ab67a8ea6d693af1287f7e9acec206d1213ff107a928e85e173741e1d594196f99fec50e9dde404b09164f39dec5864c767212154ffe1caa6af0bc5af + languageName: node + linkType: hard + +"@babel/plugin-syntax-optional-chaining@npm:^7.8.3": + version: 7.8.3 + resolution: "@babel/plugin-syntax-optional-chaining@npm:7.8.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.8.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/46edddf2faa6ebf94147b8e8540dfc60a5ab718e2de4d01b2c0bdf250a4d642c2bd47cbcbb739febcb2bf75514dbcefad3c52208787994b8d0f8822490f55e81 + languageName: node + linkType: hard + +"@babel/plugin-syntax-top-level-await@npm:^7.8.3": + version: 7.14.5 + resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.14.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/14bf6e65d5bc1231ffa9def5f0ef30b19b51c218fcecaa78cd1bdf7939dfdf23f90336080b7f5196916368e399934ce5d581492d8292b46a2fb569d8b2da106f + languageName: node + linkType: hard + +"@babel/plugin-syntax-typescript@npm:^7.7.2": + version: 7.24.1 + resolution: "@babel/plugin-syntax-typescript@npm:7.24.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.24.0" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/7a81e277dcfe3138847e8e5944e02a42ff3c2e864aea6f33fd9b70d1556d12b0e70f0d56cc1985d353c91bcbf8fe163e6cc17418da21129b7f7f1d8b9ac00c93 + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.9.2": + version: 7.24.4 + resolution: "@babel/runtime@npm:7.24.4" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/785aff96a3aa8ff97f90958e1e8a7b1d47f793b204b47c6455eaadc3f694f48c97cd5c0a921fe3596d818e71f18106610a164fb0f1c71fd68c622a58269d537c + languageName: node + linkType: hard + "@babel/runtime@npm:^7.23.2": version: 7.23.9 resolution: "@babel/runtime@npm:7.23.9" @@ -28,6 +401,62 @@ __metadata: languageName: node linkType: hard +"@babel/template@npm:^7.22.15, @babel/template@npm:^7.24.0, @babel/template@npm:^7.3.3": + version: 7.24.0 + resolution: "@babel/template@npm:7.24.0" + dependencies: + "@babel/code-frame": "npm:^7.23.5" + "@babel/parser": "npm:^7.24.0" + "@babel/types": "npm:^7.24.0" + checksum: 10c0/9d3dd8d22fe1c36bc3bdef6118af1f4b030aaf6d7d2619f5da203efa818a2185d717523486c111de8d99a8649ddf4bbf6b2a7a64962d8411cf6a8fa89f010e54 + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.24.1": + version: 7.24.1 + resolution: "@babel/traverse@npm:7.24.1" + dependencies: + "@babel/code-frame": "npm:^7.24.1" + "@babel/generator": "npm:^7.24.1" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-hoist-variables": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + "@babel/parser": "npm:^7.24.1" + "@babel/types": "npm:^7.24.0" + debug: "npm:^4.3.1" + globals: "npm:^11.1.0" + checksum: 10c0/c087b918f6823776537ba246136c70e7ce0719fc05361ebcbfd16f4e6f2f6f1f8f4f9167f1d9b675f27d12074839605189cc9d689de20b89a85e7c140f23daab + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.8.3": + version: 7.24.0 + resolution: "@babel/types@npm:7.24.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.23.4" + "@babel/helper-validator-identifier": "npm:^7.22.20" + to-fast-properties: "npm:^2.0.0" + checksum: 10c0/777a0bb5dbe038ca4c905fdafb1cdb6bdd10fe9d63ce13eca0bd91909363cbad554a53dc1f902004b78c1dcbc742056f877f2c99eeedff647333b1fadf51235d + languageName: node + linkType: hard + +"@bcoe/v8-coverage@npm:^0.2.3": + version: 0.2.3 + resolution: "@bcoe/v8-coverage@npm:0.2.3" + checksum: 10c0/6b80ae4cb3db53f486da2dc63b6e190a74c8c3cca16bb2733f234a0b6a9382b09b146488ae08e2b22cf00f6c83e20f3e040a2f7894f05c045c946d6a090b1d52 + languageName: node + linkType: hard + +"@cspotcode/source-map-support@npm:^0.8.0": + version: 0.8.1 + resolution: "@cspotcode/source-map-support@npm:0.8.1" + dependencies: + "@jridgewell/trace-mapping": "npm:0.3.9" + checksum: 10c0/05c5368c13b662ee4c122c7bfbe5dc0b613416672a829f3e78bc49a357a197e0218d6e74e7c66cfcd04e15a179acab080bd3c69658c9fbefd0e1ccd950a07fc6 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -109,6 +538,256 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/load-nyc-config@npm:^1.0.0": + version: 1.1.0 + resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" + dependencies: + camelcase: "npm:^5.3.1" + find-up: "npm:^4.1.0" + get-package-type: "npm:^0.1.0" + js-yaml: "npm:^3.13.1" + resolve-from: "npm:^5.0.0" + checksum: 10c0/dd2a8b094887da5a1a2339543a4933d06db2e63cbbc2e288eb6431bd832065df0c099d091b6a67436e71b7d6bf85f01ce7c15f9253b4cbebcc3b9a496165ba42 + languageName: node + linkType: hard + +"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10c0/7be408781d0a6f657e969cbec13b540c329671819c2f57acfad0dae9dbfe2c9be859f38fe99b35dba9ff1536937dc6ddc69fdcd2794812fa3c647a1619797f6c + languageName: node + linkType: hard + +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10c0/934f7bf73190f029ac0f96662c85cd276ec460d407baf6b0dbaec2872e157db4d55a7ee0b1c43b18874602f662b37cb973dda469a4e6d88b4e4845b521adeeb2 + languageName: node + linkType: hard + +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" + dependencies: + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + checksum: 10c0/c7b1b40c618f8baf4d00609022d2afa086d9c6acc706f303a70bb4b67275868f620ad2e1a9efc5edd418906157337cce50589a627a6400bbdf117d351b91ef86 + languageName: node + linkType: hard + +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10c0/60b79d23a5358dc50d9510d726443316253ecda3a7fb8072e1526b3e0d3b14f066ee112db95699b7a43ad3f0b61b750c72e28a5a1cac361d7a2bb34747fa938a + languageName: node + linkType: hard + +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b41f193fb697d3ced134349250aed6ccea075e48c4f803159db102b826a4e473397c68c31118259868fd69a5cba70e97e1c26d2c2ff716ca39dc73a2ccec037e + languageName: node + linkType: hard + +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" + "@types/node": "npm:*" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/cf0a8bcda801b28dc2e2b2ba36302200ee8104a45ad7a21e6c234148932f826cb3bc57c8df3b7b815aeea0861d7b6ca6f0d4778f93b9219398ef28749e03595c + languageName: node + linkType: hard + +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10c0/a385c99396878fe6e4460c43bd7bb0a5cc52befb462cc6e7f2a3810f9e7bcce7cdeb51908fd530391ee452dc856c98baa2c5f5fa8a5b30b071d31ef7f6955cea + languageName: node + linkType: hard + +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10c0/a754402a799541c6e5aff2c8160562525e2a47e7d568f01ebfc4da66522de39cbb809bbb0a841c7052e4270d79214e70aec3c169e4eae42a03bc1a8a20cb9fa2 + languageName: node + linkType: hard + +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": "npm:^0.27.8" + checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be + languageName: node + linkType: hard + +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10c0/a2f177081830a2e8ad3f2e29e20b63bd40bade294880b595acf2fc09ec74b6a9dd98f126a2baa2bf4941acd89b13a4ade5351b3885c224107083a0059b60a219 + languageName: node + linkType: hard + +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10c0/7de54090e54a674ca173470b55dc1afdee994f2d70d185c80236003efd3fa2b753fff51ffcdda8e2890244c411fd2267529d42c4a50a8303755041ee493e6a04 + languageName: node + linkType: hard + +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10c0/593a8c4272797bb5628984486080cbf57aed09c7cfdc0a634e8c06c38c6bef329c46c0016e84555ee55d1cd1f381518cf1890990ff845524c1123720c8c1481b + languageName: node + linkType: hard + +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + babel-plugin-istanbul: "npm:^6.1.1" + chalk: "npm:^4.0.0" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pirates: "npm:^4.0.4" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^4.0.2" + checksum: 10c0/7f4a7f73dcf45dfdf280c7aa283cbac7b6e5a904813c3a93ead7e55873761fc20d5c4f0191d2019004fac6f55f061c82eb3249c2901164ad80e362e7a7ede5a6 + languageName: node + linkType: hard + +"@jest/types@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/types@npm:29.6.3" + dependencies: + "@jest/schemas": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + "@types/istanbul-reports": "npm:^3.0.0" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.8" + chalk: "npm:^4.0.0" + checksum: 10c0/ea4e493dd3fb47933b8ccab201ae573dcc451f951dc44ed2a86123cd8541b82aa9d2b1031caf9b1080d6673c517e2dcc25a44b2dc4f3fbc37bfc965d444888c0 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.2": version: 0.3.4 resolution: "@jridgewell/gen-mapping@npm:0.3.4" @@ -120,7 +799,18 @@ __metadata: languageName: node linkType: hard -"@jridgewell/resolve-uri@npm:^3.1.0": +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.0.3, @jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e @@ -134,6 +824,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 + languageName: node + linkType: hard + "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" @@ -141,6 +838,26 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:0.3.9": + version: 0.3.9 + resolution: "@jridgewell/trace-mapping@npm:0.3.9" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.0.3" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + checksum: 10c0/fa425b606d7c7ee5bfa6a31a7b050dd5814b4082f318e0e4190f991902181b4330f43f4805db1dd4f2433fd0ed9cc7a7b9c2683f1deeab1df1b0a98b1e24055b + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": + version: 0.3.25 + resolution: "@jridgewell/trace-mapping@npm:0.3.25" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.23 resolution: "@jridgewell/trace-mapping@npm:0.3.23" @@ -151,81 +868,81 @@ __metadata: languageName: node linkType: hard -"@next/env@npm:14.1.0": - version: 14.1.0 - resolution: "@next/env@npm:14.1.0" - checksum: 10c0/f45ce1e3dad87cdbddc58b06bd411f44a6d21dfc2c344d02a5e1b07f56fbc9a39e192c0b0917df9f2e9e4e2156306a8c78f173ca4b53932c2793e67797462a23 +"@next/env@npm:14.2.3": + version: 14.2.3 + resolution: "@next/env@npm:14.2.3" + checksum: 10c0/25ab3ac2739c8e5ce35e1f50373238c5c428ab6b01d448ba78a6068dcdef88978b64f9a92790c324b2926ccc41390a67107154a0b0fee32fe980a485f4ef20d8 languageName: node linkType: hard -"@next/eslint-plugin-next@npm:14.1.0": - version: 14.1.0 - resolution: "@next/eslint-plugin-next@npm:14.1.0" +"@next/eslint-plugin-next@npm:14.2.3": + version: 14.2.3 + resolution: "@next/eslint-plugin-next@npm:14.2.3" dependencies: glob: "npm:10.3.10" - checksum: 10c0/d8753d8258bef471ba1296f760b092c0a17e89ddc937bf16b9399725d05b6426e58e3c8eb4efb8e8f027025804ecea3b714a7b7c75682d019e53ea8d181b8632 + checksum: 10c0/de9af2c7465cce4eb4cb50654aa2548d4d2af788c8992d02e2b863b1bf4f99e3b6604d1f0775f8e50aca6a53cf30d51f4ac56810d1625d401548267ca5f1d883 languageName: node linkType: hard -"@next/swc-darwin-arm64@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-darwin-arm64@npm:14.1.0" +"@next/swc-darwin-arm64@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-darwin-arm64@npm:14.2.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@next/swc-darwin-x64@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-darwin-x64@npm:14.1.0" +"@next/swc-darwin-x64@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-darwin-x64@npm:14.2.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@next/swc-linux-arm64-gnu@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-linux-arm64-gnu@npm:14.1.0" +"@next/swc-linux-arm64-gnu@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-linux-arm64-gnu@npm:14.2.3" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-arm64-musl@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-linux-arm64-musl@npm:14.1.0" +"@next/swc-linux-arm64-musl@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-linux-arm64-musl@npm:14.2.3" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@next/swc-linux-x64-gnu@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-linux-x64-gnu@npm:14.1.0" +"@next/swc-linux-x64-gnu@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-linux-x64-gnu@npm:14.2.3" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@next/swc-linux-x64-musl@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-linux-x64-musl@npm:14.1.0" +"@next/swc-linux-x64-musl@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-linux-x64-musl@npm:14.2.3" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@next/swc-win32-arm64-msvc@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-win32-arm64-msvc@npm:14.1.0" +"@next/swc-win32-arm64-msvc@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-win32-arm64-msvc@npm:14.2.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@next/swc-win32-ia32-msvc@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-win32-ia32-msvc@npm:14.1.0" +"@next/swc-win32-ia32-msvc@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-win32-ia32-msvc@npm:14.2.3" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@next/swc-win32-x64-msvc@npm:14.1.0": - version: 14.1.0 - resolution: "@next/swc-win32-x64-msvc@npm:14.1.0" +"@next/swc-win32-x64-msvc@npm:14.2.3": + version: 14.2.3 + resolution: "@next/swc-win32-x64-msvc@npm:14.2.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -302,12 +1019,236 @@ __metadata: languageName: node linkType: hard -"@swc/helpers@npm:0.5.2": - version: 0.5.2 - resolution: "@swc/helpers@npm:0.5.2" +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e + languageName: node + linkType: hard + +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.1 + resolution: "@sinonjs/commons@npm:3.0.1" + dependencies: + type-detect: "npm:4.0.8" + checksum: 10c0/1227a7b5bd6c6f9584274db996d7f8cee2c8c350534b9d0141fc662eaf1f292ea0ae3ed19e5e5271c8fd390d27e492ca2803acd31a1978be2cdc6be0da711403 + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10c0/2e2fb6cc57f227912814085b7b01fede050cd4746ea8d49a1e44d5a0e56a804663b0340ae2f11af7559ea9bf4d087a11f2f646197a660ea3cb04e19efc04aa63 + languageName: node + linkType: hard + +"@swc/counter@npm:^0.1.3": + version: 0.1.3 + resolution: "@swc/counter@npm:0.1.3" + checksum: 10c0/8424f60f6bf8694cfd2a9bca45845bce29f26105cda8cf19cdb9fd3e78dc6338699e4db77a89ae449260bafa1cc6bec307e81e7fb96dbf7dcfce0eea55151356 + languageName: node + linkType: hard + +"@swc/helpers@npm:0.5.5": + version: 0.5.5 + resolution: "@swc/helpers@npm:0.5.5" dependencies: + "@swc/counter": "npm:^0.1.3" tslib: "npm:^2.4.0" - checksum: 10c0/b6fa49bcf6c00571d0eb7837b163f8609960d4d77538160585e27ed167361e9776bd6e5eb9646ffac2fb4d43c58df9ca50dab9d96ab097e6591bc82a75fd1164 + checksum: 10c0/21a9b9cfe7e00865f9c9f3eb4c1cc5b397143464f7abee76a2c5366e591e06b0155b5aac93fe8269ef8d548df253f6fd931e9ddfc0fd12efd405f90f45506e7d + languageName: node + linkType: hard + +"@testing-library/dom@npm:^9.0.0": + version: 9.3.4 + resolution: "@testing-library/dom@npm:9.3.4" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.1.3" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10c0/147da340e8199d7f98f3a4ad8aa22ed55b914b83957efa5eb22bfea021a979ebe5a5182afa9c1e5b7a5f99a7f6744a5a4d9325ae46ec3b33b5a15aed8750d794 + languageName: node + linkType: hard + +"@testing-library/jest-dom@npm:^6.4.2": + version: 6.4.2 + resolution: "@testing-library/jest-dom@npm:6.4.2" + dependencies: + "@adobe/css-tools": "npm:^4.3.2" + "@babel/runtime": "npm:^7.9.2" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.15" + redent: "npm:^3.0.0" + peerDependencies: + "@jest/globals": ">= 28" + "@types/bun": "*" + "@types/jest": ">= 28" + jest: ">= 28" + vitest: ">= 0.32" + peerDependenciesMeta: + "@jest/globals": + optional: true + "@types/bun": + optional: true + "@types/jest": + optional: true + jest: + optional: true + vitest: + optional: true + checksum: 10c0/e7eba527b34ce30cde94424d2ec685bdfed51daaafb7df9b68b51aec6052e99a50c8bfe654612dacdf857a1eb81d68cf294fc89de558ee3a992bf7a6019fffcc + languageName: node + linkType: hard + +"@testing-library/react@npm:^14.2.2": + version: 14.2.2 + resolution: "@testing-library/react@npm:14.2.2" + dependencies: + "@babel/runtime": "npm:^7.12.5" + "@testing-library/dom": "npm:^9.0.0" + "@types/react-dom": "npm:^18.0.0" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10c0/ab36707f6701a4a56dd217e16e00d6326e0f760bb2e716245422c7500a0b94efcd351d0aa89c4fab2916e6ebc68c983cec6b3ae0804de813cafc913a612668f6 + languageName: node + linkType: hard + +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: 10c0/073bfa548026b1ebaf1659eb8961e526be22fa77139b10d60e712f46d2f0f05f4e6c8bec62a087d41088ee9e29faa7f54838568e475ab2f776171003c3920858 + languageName: node + linkType: hard + +"@tsconfig/node10@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node10@npm:1.0.11" + checksum: 10c0/28a0710e5d039e0de484bdf85fee883bfd3f6a8980601f4d44066b0a6bcd821d31c4e231d1117731c4e24268bd4cf2a788a6787c12fc7f8d11014c07d582783c + languageName: node + linkType: hard + +"@tsconfig/node12@npm:^1.0.7": + version: 1.0.11 + resolution: "@tsconfig/node12@npm:1.0.11" + checksum: 10c0/dddca2b553e2bee1308a056705103fc8304e42bb2d2cbd797b84403a223b25c78f2c683ec3e24a095e82cd435387c877239bffcb15a590ba817cd3f6b9a99fd9 + languageName: node + linkType: hard + +"@tsconfig/node14@npm:^1.0.0": + version: 1.0.3 + resolution: "@tsconfig/node14@npm:1.0.3" + checksum: 10c0/67c1316d065fdaa32525bc9449ff82c197c4c19092b9663b23213c8cbbf8d88b6ed6a17898e0cbc2711950fbfaf40388938c1c748a2ee89f7234fc9e7fe2bf44 + languageName: node + linkType: hard + +"@tsconfig/node16@npm:^1.0.2": + version: 1.0.4 + resolution: "@tsconfig/node16@npm:1.0.4" + checksum: 10c0/05f8f2734e266fb1839eb1d57290df1664fe2aa3b0fdd685a9035806daa635f7519bf6d5d9b33f6e69dd545b8c46bd6e2b5c79acb2b1f146e885f7f11a42a5bb + languageName: node + linkType: hard + +"@types/aria-query@npm:^5.0.1": + version: 5.0.4 + resolution: "@types/aria-query@npm:5.0.4" + checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08 + languageName: node + linkType: hard + +"@types/babel__core@npm:^7.1.14": + version: 7.20.5 + resolution: "@types/babel__core@npm:7.20.5" + dependencies: + "@babel/parser": "npm:^7.20.7" + "@babel/types": "npm:^7.20.7" + "@types/babel__generator": "npm:*" + "@types/babel__template": "npm:*" + "@types/babel__traverse": "npm:*" + checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff + languageName: node + linkType: hard + +"@types/babel__generator@npm:*": + version: 7.6.8 + resolution: "@types/babel__generator@npm:7.6.8" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10c0/f0ba105e7d2296bf367d6e055bb22996886c114261e2cb70bf9359556d0076c7a57239d019dee42bb063f565bade5ccb46009bce2044b2952d964bf9a454d6d2 + languageName: node + linkType: hard + +"@types/babel__template@npm:*": + version: 7.4.4 + resolution: "@types/babel__template@npm:7.4.4" + dependencies: + "@babel/parser": "npm:^7.1.0" + "@babel/types": "npm:^7.0.0" + checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b + languageName: node + linkType: hard + +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": + version: 7.20.5 + resolution: "@types/babel__traverse@npm:7.20.5" + dependencies: + "@babel/types": "npm:^7.20.7" + checksum: 10c0/033abcb2f4c084ad33e30c3efaad82161240f351e3c71b6154ed289946b33b363696c0fbd42502b68e4582a87413c418321f40eb1ea863e34fe525641345e05b + languageName: node + linkType: hard + +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/235d2fc69741448e853333b7c3d1180a966dd2b8972c8cbcd6b2a0c6cd7f8d582ab2b8e58219dbc62cce8f1b40aa317ff78ea2201cdd8249da5025adebed6f0b + languageName: node + linkType: hard + +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": + version: 2.0.6 + resolution: "@types/istanbul-lib-coverage@npm:2.0.6" + checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 + languageName: node + linkType: hard + +"@types/istanbul-lib-report@npm:*": + version: 3.0.3 + resolution: "@types/istanbul-lib-report@npm:3.0.3" + dependencies: + "@types/istanbul-lib-coverage": "npm:*" + checksum: 10c0/247e477bbc1a77248f3c6de5dadaae85ff86ac2d76c5fc6ab1776f54512a745ff2a5f791d22b942e3990ddbd40f3ef5289317c4fca5741bedfaa4f01df89051c + languageName: node + linkType: hard + +"@types/istanbul-reports@npm:^3.0.0": + version: 3.0.4 + resolution: "@types/istanbul-reports@npm:3.0.4" + dependencies: + "@types/istanbul-lib-report": "npm:*" + checksum: 10c0/1647fd402aced5b6edac87274af14ebd6b3a85447ef9ad11853a70fd92a98d35f81a5d3ea9fcb5dbb5834e800c6e35b64475e33fcae6bfa9acc70d61497c54ee + languageName: node + linkType: hard + +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" + dependencies: + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10c0/3d4b2a3eab145674ee6da482607c5e48977869109f0f62560bf91ae1a792c9e847ac7c6aaf243ed2e97333cb3c51aef314ffa54a19ef174b8f9592dfcb836b25 languageName: node linkType: hard @@ -318,6 +1259,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:*": + version: 20.12.5 + resolution: "@types/node@npm:20.12.5" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10c0/2da65516fba98f0417620e42bddbe53e144d4782d69cd37f99df2537c6850b9cfbdb8a017f02c61e9a074bcac84f9f3f221b250474ac8c6b95d507a47e8d53f9 + languageName: node + linkType: hard + "@types/node@npm:^20": version: 20.11.20 resolution: "@types/node@npm:20.11.20" @@ -343,6 +1293,15 @@ __metadata: languageName: node linkType: hard +"@types/react-dom@npm:^18.0.0": + version: 18.2.24 + resolution: "@types/react-dom@npm:18.2.24" + dependencies: + "@types/react": "npm:*" + checksum: 10c0/9ec38e5ab4727c56ef17bd8e938ead88748ba19db314b8d9807714a5cae430f5b799514667b221b4f2dc8d9b4ca17dd1c3da8c41c083c2de9eddcc31bec6b8ff + languageName: node + linkType: hard + "@types/react@npm:*, @types/react@npm:^18": version: 18.2.58 resolution: "@types/react@npm:18.2.58" @@ -361,47 +1320,77 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:^5.4.2 || ^6.0.0": - version: 6.21.0 - resolution: "@typescript-eslint/parser@npm:6.21.0" +"@types/stack-utils@npm:^2.0.0": + version: 2.0.3 + resolution: "@types/stack-utils@npm:2.0.3" + checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c + languageName: node + linkType: hard + +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + +"@types/yargs-parser@npm:*": + version: 21.0.3 + resolution: "@types/yargs-parser@npm:21.0.3" + checksum: 10c0/e71c3bd9d0b73ca82e10bee2064c384ab70f61034bbfb78e74f5206283fc16a6d85267b606b5c22cb2a3338373586786fed595b2009825d6a9115afba36560a0 + languageName: node + linkType: hard + +"@types/yargs@npm:^17.0.8": + version: 17.0.32 + resolution: "@types/yargs@npm:17.0.32" + dependencies: + "@types/yargs-parser": "npm:*" + checksum: 10c0/2095e8aad8a4e66b86147415364266b8d607a3b95b4239623423efd7e29df93ba81bb862784a6e08664f645cc1981b25fd598f532019174cd3e5e1e689e1cccf + languageName: node + linkType: hard + +"@typescript-eslint/parser@npm:^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0": + version: 7.2.0 + resolution: "@typescript-eslint/parser@npm:7.2.0" dependencies: - "@typescript-eslint/scope-manager": "npm:6.21.0" - "@typescript-eslint/types": "npm:6.21.0" - "@typescript-eslint/typescript-estree": "npm:6.21.0" - "@typescript-eslint/visitor-keys": "npm:6.21.0" + "@typescript-eslint/scope-manager": "npm:7.2.0" + "@typescript-eslint/types": "npm:7.2.0" + "@typescript-eslint/typescript-estree": "npm:7.2.0" + "@typescript-eslint/visitor-keys": "npm:7.2.0" debug: "npm:^4.3.4" peerDependencies: - eslint: ^7.0.0 || ^8.0.0 + eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/a8f99820679decd0d115c0af61903fb1de3b1b5bec412dc72b67670bf636de77ab07f2a68ee65d6da7976039bbf636907f9d5ca546db3f0b98a31ffbc225bc7d + checksum: 10c0/11ce36c68212fdbf98fc6fd32ba0977d46b645fd669a3f4fdb8be2036225f86ad005b31a66f97097e90517c44c92cf9cc5fb1d6e9647ee2fa125c4af21cdb477 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:6.21.0": - version: 6.21.0 - resolution: "@typescript-eslint/scope-manager@npm:6.21.0" +"@typescript-eslint/scope-manager@npm:7.2.0": + version: 7.2.0 + resolution: "@typescript-eslint/scope-manager@npm:7.2.0" dependencies: - "@typescript-eslint/types": "npm:6.21.0" - "@typescript-eslint/visitor-keys": "npm:6.21.0" - checksum: 10c0/eaf868938d811cbbea33e97e44ba7050d2b6892202cea6a9622c486b85ab1cf801979edf78036179a8ba4ac26f1dfdf7fcc83a68c1ff66be0b3a8e9a9989b526 + "@typescript-eslint/types": "npm:7.2.0" + "@typescript-eslint/visitor-keys": "npm:7.2.0" + checksum: 10c0/4d088c127e6ba1a7de8567f70684779083be24b48746c3b4a86a0ec7062bca58693ee08482349ad6572a17ada8aa6f26b74d1c7139c8fcf7101fa09a572e0ea6 languageName: node linkType: hard -"@typescript-eslint/types@npm:6.21.0": - version: 6.21.0 - resolution: "@typescript-eslint/types@npm:6.21.0" - checksum: 10c0/020631d3223bbcff8a0da3efbdf058220a8f48a3de221563996ad1dcc30d6c08dadc3f7608cc08830d21c0d565efd2db19b557b9528921c78aabb605eef2d74d +"@typescript-eslint/types@npm:7.2.0": + version: 7.2.0 + resolution: "@typescript-eslint/types@npm:7.2.0" + checksum: 10c0/135aae061720185855bea61ea6cfd33f4801d2de57f65e50079bbdb505100f844632aa4e4bdeec9e9e79d29aaddad949178d0e918e41867da6ab4b1390820e33 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:6.21.0": - version: 6.21.0 - resolution: "@typescript-eslint/typescript-estree@npm:6.21.0" +"@typescript-eslint/typescript-estree@npm:7.2.0": + version: 7.2.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.2.0" dependencies: - "@typescript-eslint/types": "npm:6.21.0" - "@typescript-eslint/visitor-keys": "npm:6.21.0" + "@typescript-eslint/types": "npm:7.2.0" + "@typescript-eslint/visitor-keys": "npm:7.2.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -411,17 +1400,17 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/af1438c60f080045ebb330155a8c9bb90db345d5069cdd5d01b67de502abb7449d6c75500519df829f913a6b3f490ade3e8215279b6bdc63d0fb0ae61034df5f + checksum: 10c0/2730bb17730e6f3ca4061f00688a70386a808f5d174fdeb757c3cfa92c455373f69080df33237c1a8970e818af0cea0ae5a083970ed8ba493f3b04458c6f9271 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:6.21.0": - version: 6.21.0 - resolution: "@typescript-eslint/visitor-keys@npm:6.21.0" +"@typescript-eslint/visitor-keys@npm:7.2.0": + version: 7.2.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.2.0" dependencies: - "@typescript-eslint/types": "npm:6.21.0" + "@typescript-eslint/types": "npm:7.2.0" eslint-visitor-keys: "npm:^3.4.1" - checksum: 10c0/7395f69739cfa1cb83c1fb2fad30afa2a814756367302fb4facd5893eff66abc807e8d8f63eba94ed3b0fe0c1c996ac9a1680bcbf0f83717acedc3f2bb724fbf + checksum: 10c0/2d7467495b2b76f3edb1b3047e97076c2242e7eca6d50bbbdd88219f9ff754dbcb9334a0568fe0ceb4c562823980938bd278aa2ba53da6343e7d99a167924f24 languageName: node linkType: hard @@ -432,6 +1421,13 @@ __metadata: languageName: node linkType: hard +"abab@npm:^2.0.6": + version: 2.0.6 + resolution: "abab@npm:2.0.6" + checksum: 10c0/0b245c3c3ea2598fe0025abf7cc7bb507b06949d51e8edae5d12c1b847a0a0c09639abcb94788332b4e2044ac4491c1e8f571b51c7826fd4b0bda1685ad4a278 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -439,6 +1435,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" + dependencies: + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 10c0/7437f58e92d99292dbebd0e79531af27d706c9f272f31c675d793da6c82d897e75302a8744af13c7f7978a8399840f14a353b60cf21014647f71012982456d2b + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -448,7 +1454,14 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.9.0": +"acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": + version: 8.3.2 + resolution: "acorn-walk@npm:8.3.2" + checksum: 10c0/7e2a8dad5480df7f872569b9dccff2f3da7e65f5353686b1d6032ab9f4ddf6e3a2cb83a9b52cf50b1497fd522154dda92f0abf7153290cc79cd14721ff121e52 + languageName: node + linkType: hard + +"acorn@npm:^8.1.0, acorn@npm:^8.4.1, acorn@npm:^8.8.1, acorn@npm:^8.9.0": version: 8.11.3 resolution: "acorn@npm:8.11.3" bin: @@ -457,6 +1470,15 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + "agent-base@npm:^7.0.2, agent-base@npm:^7.1.0": version: 7.1.0 resolution: "agent-base@npm:7.1.0" @@ -488,6 +1510,15 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^4.2.1": + version: 4.3.2 + resolution: "ansi-escapes@npm:4.3.2" + dependencies: + type-fest: "npm:^0.21.3" + checksum: 10c0/da917be01871525a3dfcf925ae2977bc59e8c513d4423368645634bf5d4ceba5401574eb705c1e92b79f7292af5a656f78c5725a4b0e1cec97c4b413705c1d50 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -502,6 +1533,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -511,6 +1551,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + "ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -525,7 +1572,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -535,6 +1582,13 @@ __metadata: languageName: node linkType: hard +"arg@npm:^4.1.0": + version: 4.1.3 + resolution: "arg@npm:4.1.3" + checksum: 10c0/070ff801a9d236a6caa647507bdcc7034530604844d64408149a26b9e87c2f97650055c0f049abd1efc024b334635c01f29e0b632b371ac3f26130f4cf65997a + languageName: node + linkType: hard + "arg@npm:^5.0.2": version: 5.0.2 resolution: "arg@npm:5.0.2" @@ -542,6 +1596,15 @@ __metadata: languageName: node linkType: hard +"argparse@npm:^1.0.7": + version: 1.0.10 + resolution: "argparse@npm:1.0.10" + dependencies: + sprintf-js: "npm:~1.0.2" + checksum: 10c0/b2972c5c23c63df66bca144dbc65d180efa74f25f8fd9b7d9a0a6c88ae839db32df3d54770dcb6460cf840d232b60695d1a6b1053f599d84e73f7437087712de + languageName: node + linkType: hard + "argparse@npm:^2.0.1": version: 2.0.1 resolution: "argparse@npm:2.0.1" @@ -549,7 +1612,16 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:^5.3.0": +"aria-query@npm:5.1.3": + version: 5.1.3 + resolution: "aria-query@npm:5.1.3" + dependencies: + deep-equal: "npm:^2.0.5" + checksum: 10c0/edcbc8044c4663d6f88f785e983e6784f98cb62b4ba1e9dd8d61b725d0203e4cfca38d676aee984c31f354103461102a3d583aa4fbe4fd0a89b679744f4e5faf + languageName: node + linkType: hard + +"aria-query@npm:^5.0.0, aria-query@npm:^5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" dependencies: @@ -558,7 +1630,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.1": +"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1": version: 1.0.1 resolution: "array-buffer-byte-length@npm:1.0.1" dependencies: @@ -683,6 +1755,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + "autoprefixer@npm:^10.0.1": version: 10.4.17 resolution: "autoprefixer@npm:10.4.17" @@ -717,6 +1796,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.8": + version: 1.6.8 + resolution: "axios@npm:1.6.8" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/0f22da6f490335479a89878bc7d5a1419484fbb437b564a80c34888fc36759ae4f56ea28d55a191695e5ed327f0bad56e7ff60fb6770c14d1be6501505d47ab9 + languageName: node + linkType: hard + "axobject-query@npm:^3.2.1": version: 3.2.1 resolution: "axobject-query@npm:3.2.1" @@ -726,6 +1816,82 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" + dependencies: + "@jest/transform": "npm:^29.7.0" + "@types/babel__core": "npm:^7.1.14" + babel-plugin-istanbul: "npm:^6.1.1" + babel-preset-jest: "npm:^29.6.3" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.8.0 + checksum: 10c0/2eda9c1391e51936ca573dd1aedfee07b14c59b33dbe16ef347873ddd777bcf6e2fc739681e9e9661ab54ef84a3109a03725be2ac32cd2124c07ea4401cbe8c1 + languageName: node + linkType: hard + +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-instrument: "npm:^5.0.4" + test-exclude: "npm:^6.0.0" + checksum: 10c0/1075657feb705e00fd9463b329921856d3775d9867c5054b449317d39153f8fbcebd3e02ebf00432824e647faff3683a9ca0a941325ef1afe9b3c4dd51b24beb + languageName: node + linkType: hard + +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" + dependencies: + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.1.14" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 10c0/7e6451caaf7dce33d010b8aafb970e62f1b0c0b57f4978c37b0d457bbcf0874d75a395a102daf0bae0bd14eafb9f6e9a165ee5e899c0a4f1f3bb2e07b304ed2e + languageName: node + linkType: hard + +"babel-preset-current-node-syntax@npm:^1.0.0": + version: 1.0.1 + resolution: "babel-preset-current-node-syntax@npm:1.0.1" + dependencies: + "@babel/plugin-syntax-async-generators": "npm:^7.8.4" + "@babel/plugin-syntax-bigint": "npm:^7.8.3" + "@babel/plugin-syntax-class-properties": "npm:^7.8.3" + "@babel/plugin-syntax-import-meta": "npm:^7.8.3" + "@babel/plugin-syntax-json-strings": "npm:^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators": "npm:^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + "@babel/plugin-syntax-numeric-separator": "npm:^7.8.3" + "@babel/plugin-syntax-object-rest-spread": "npm:^7.8.3" + "@babel/plugin-syntax-optional-catch-binding": "npm:^7.8.3" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + "@babel/plugin-syntax-top-level-await": "npm:^7.8.3" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/5ba39a3a0e6c37d25e56a4fb843be632dac98d54706d8a0933f9bcb1a07987a96d55c2b5a6c11788a74063fb2534fe68c1f1dbb6c93626850c785e0938495627 + languageName: node + linkType: hard + +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" + dependencies: + babel-plugin-jest-hoist: "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/ec5fd0276b5630b05f0c14bb97cc3815c6b31600c683ebb51372e54dcb776cff790bdeeabd5b8d01ede375a040337ccbf6a3ccd68d3a34219125945e167ad943 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -782,6 +1948,22 @@ __metadata: languageName: node linkType: hard +"bser@npm:2.1.1": + version: 2.1.1 + resolution: "bser@npm:2.1.1" + dependencies: + node-int64: "npm:^0.4.0" + checksum: 10c0/24d8dfb7b6d457d73f32744e678a60cc553e4ec0e9e1a01cf614b44d85c3c87e188d3cc78ef0442ce5032ee6818de20a0162ba1074725c0d08908f62ea979227 + languageName: node + linkType: hard + +"buffer-from@npm:^1.0.0": + version: 1.1.2 + resolution: "buffer-from@npm:1.1.2" + checksum: 10c0/124fff9d66d691a86d3b062eff4663fe437a9d9ee4b47b1b9e97f5a5d14f6d5399345db80f796827be7c95e70a8e765dd404b7c3ff3b3324f98e9b0c8826cc34 + languageName: node + linkType: hard + "busboy@npm:1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -838,6 +2020,20 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:^5.3.1": + version: 5.3.1 + resolution: "camelcase@npm:5.3.1" + checksum: 10c0/92ff9b443bfe8abb15f2b1513ca182d16126359ad4f955ebc83dc4ddcc4ef3fdd2c078bc223f2673dc223488e75c99b16cc4d056624374b799e6a1555cf61b23 + languageName: node + linkType: hard + +"camelcase@npm:^6.2.0": + version: 6.3.0 + resolution: "camelcase@npm:6.3.0" + checksum: 10c0/0d701658219bd3116d12da3eab31acddb3f9440790c0792e0d398f0a520a6a4058018e546862b6fba89d7ae990efaeb97da71e1913e9ebf5a8b5621a3d55c710 + languageName: node + linkType: hard + "caniuse-lite@npm:^1.0.30001578, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001587": version: 1.0.30001589 resolution: "caniuse-lite@npm:1.0.30001589" @@ -845,7 +2041,28 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": +"chalk@npm:^2.4.2": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 + languageName: node + linkType: hard + +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: "npm:^4.1.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/ee650b0a065b3d7a6fda258e75d3a86fc8e4effa55871da730a9e42ccb035bf5fd203525e5a1ef45ec2582ecc4f65b47eb11357c526b84dd29a14fb162c414d2 + languageName: node + linkType: hard + +"chalk@npm:^4.0.0, chalk@npm:^4.1.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -855,6 +2072,13 @@ __metadata: languageName: node linkType: hard +"char-regex@npm:^1.0.2": + version: 1.0.2 + resolution: "char-regex@npm:1.0.2" + checksum: 10c0/57a09a86371331e0be35d9083ba429e86c4f4648ecbe27455dbfb343037c16ee6fdc7f6b61f433a57cc5ded5561d71c56a150e018f40c2ffb7bc93a26dae341e + languageName: node + linkType: hard + "chokidar@npm:^3.5.3": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -881,6 +2105,20 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^3.2.0": + version: 3.9.0 + resolution: "ci-info@npm:3.9.0" + checksum: 10c0/6f0109e36e111684291d46123d491bc4e7b7a1934c3a20dea28cba89f1d4a03acd892f5f6a81ed3855c38647e285a150e3c9ba062e38943bef57fee6c1554c3a + languageName: node + linkType: hard + +"cjs-module-lexer@npm:^1.0.0": + version: 1.2.3 + resolution: "cjs-module-lexer@npm:1.2.3" + checksum: 10c0/0de9a9c3fad03a46804c0d38e7b712fb282584a9c7ef1ed44cae22fb71d9bb600309d66a9711ac36a596fd03422f5bb03e021e8f369c12a39fa1786ae531baab + languageName: node + linkType: hard + "clean-stack@npm:^2.0.0": version: 2.2.0 resolution: "clean-stack@npm:2.2.0" @@ -895,6 +2133,40 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + +"co@npm:^4.6.0": + version: 4.6.0 + resolution: "co@npm:4.6.0" + checksum: 10c0/c0e85ea0ca8bf0a50cbdca82efc5af0301240ca88ebe3644a6ffb8ffe911f34d40f8fbcf8f1d52c5ddd66706abd4d3bfcd64259f1e8e2371d4f47573b0dc8c28 + languageName: node + linkType: hard + +"collect-v8-coverage@npm:^1.0.0": + version: 1.0.2 + resolution: "collect-v8-coverage@npm:1.0.2" + checksum: 10c0/ed7008e2e8b6852c5483b444a3ae6e976e088d4335a85aa0a9db2861c5f1d31bd2d7ff97a60469b3388deeba661a619753afbe201279fb159b4b9548ab8269a1 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -904,6 +2176,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -911,6 +2190,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + "commander@npm:^4.0.0": version: 4.1.1 resolution: "commander@npm:4.1.1" @@ -925,7 +2213,38 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2": +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10c0/e7e54c280692470d3398f62a6238fd396327e01c6a0757002833f06d00afc62dd7bfe04ff2b9cd145264460e6b4d1eb8386f2925b7e567f97939843b7b0e812f + languageName: node + linkType: hard + +"create-require@npm:^1.1.0": + version: 1.1.1 + resolution: "create-require@npm:1.1.1" + checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91 + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -936,6 +2255,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525 + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -945,6 +2271,29 @@ __metadata: languageName: node linkType: hard +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: 10c0/8c4121c243baf0678c65dcac29b201ff0067dfecf978de9d5c83b2ff127a8fdefd2bfd54577f5ad8c80ed7d2c8b489ae01c82023545d010c4ecb87683fb403dd + languageName: node + linkType: hard + +"cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 10c0/d74017b209440822f9e24d8782d6d2e808a8fdd58fa626a783337222fe1c87a518ba944d4c88499031b4786e68772c99dfae616638d71906fe9f203aeaf14411 + languageName: node + linkType: hard + +"cssstyle@npm:^2.3.0": + version: 2.3.0 + resolution: "cssstyle@npm:2.3.0" + dependencies: + cssom: "npm:~0.3.6" + checksum: 10c0/863400da2a458f73272b9a55ba7ff05de40d850f22eb4f37311abebd7eff801cf1cd2fb04c4c92b8c3daed83fe766e52e4112afb7bc88d86c63a9c2256a7d178 + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.3 resolution: "csstype@npm:3.1.3" @@ -959,7 +2308,18 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" + dependencies: + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 10c0/051c3aaaf3e961904f136aab095fcf6dff4db23a7fc759dd8ba7b3e6ba03fc07ef608086caad8ab910d864bd3b5e57d0d2f544725653d77c96a2c971567045f4 + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -980,6 +2340,51 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.4.2": + version: 10.4.3 + resolution: "decimal.js@npm:10.4.3" + checksum: 10c0/6d60206689ff0911f0ce968d40f163304a6c1bc739927758e6efc7921cfa630130388966f16bf6ef6b838cb33679fbe8e7a78a2f3c478afce841fd55ac8fb8ee + languageName: node + linkType: hard + +"dedent@npm:^1.0.0": + version: 1.5.1 + resolution: "dedent@npm:1.5.1" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10c0/f8612cd5b00aab58b18bb95572dca08dc2d49720bfa7201a444c3dae430291e8a06d4928614a6ec8764d713927f44bce9c990d3b8238fca2f430990ddc17c070 + languageName: node + linkType: hard + +"deep-equal@npm:^2.0.5": + version: 2.2.3 + resolution: "deep-equal@npm:2.2.3" + dependencies: + array-buffer-byte-length: "npm:^1.0.0" + call-bind: "npm:^1.0.5" + es-get-iterator: "npm:^1.1.3" + get-intrinsic: "npm:^1.2.2" + is-arguments: "npm:^1.1.1" + is-array-buffer: "npm:^3.0.2" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + isarray: "npm:^2.0.5" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.4" + regexp.prototype.flags: "npm:^1.5.1" + side-channel: "npm:^1.0.4" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.13" + checksum: 10c0/a48244f90fa989f63ff5ef0cc6de1e4916b48ea0220a9c89a378561960814794a5800c600254482a2c8fd2e49d6c2e196131dc983976adb024c94a42dfe4949f + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -987,6 +2392,13 @@ __metadata: languageName: node linkType: hard +"deepmerge@npm:^4.2.2": + version: 4.3.1 + resolution: "deepmerge@npm:4.3.1" + checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 + languageName: node + linkType: hard + "define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.2, define-data-property@npm:^1.1.4": version: 1.1.4 resolution: "define-data-property@npm:1.1.4" @@ -1009,6 +2421,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "dequal@npm:^2.0.3": version: 2.0.3 resolution: "dequal@npm:2.0.3" @@ -1016,6 +2435,13 @@ __metadata: languageName: node linkType: hard +"detect-newline@npm:^3.0.0": + version: 3.1.0 + resolution: "detect-newline@npm:3.1.0" + checksum: 10c0/c38cfc8eeb9fda09febb44bcd85e467c970d4e3bf526095394e5a4f18bc26dd0cf6b22c69c1fa9969261521c593836db335c2795218f6d781a512aea2fb8209d + languageName: node + linkType: hard + "didyoumean@npm:^1.2.2": version: 1.2.2 resolution: "didyoumean@npm:1.2.2" @@ -1023,6 +2449,20 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + +"diff@npm:^4.0.1": + version: 4.0.2 + resolution: "diff@npm:4.0.2" + checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -1057,6 +2497,29 @@ __metadata: languageName: node linkType: hard +"dom-accessibility-api@npm:^0.5.9": + version: 0.5.16 + resolution: "dom-accessibility-api@npm:0.5.16" + checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053 + languageName: node + linkType: hard + +"dom-accessibility-api@npm:^0.6.3": + version: 0.6.3 + resolution: "dom-accessibility-api@npm:0.6.3" + checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360 + languageName: node + linkType: hard + +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" + dependencies: + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/774277cd9d4df033f852196e3c0077a34dbd15a96baa4d166e0e47138a80f4c0bdf0d94e4703e6ff5883cec56bb821a6fff84402d8a498e31de7c87eb932a294 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -1071,6 +2534,13 @@ __metadata: languageName: node linkType: hard +"emittery@npm:^0.13.1": + version: 0.13.1 + resolution: "emittery@npm:0.13.1" + checksum: 10c0/1573d0ae29ab34661b6c63251ff8f5facd24ccf6a823f19417ae8ba8c88ea450325788c67f16c99edec8de4b52ce93a10fe441ece389fd156e88ee7dab9bfa35 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -1104,6 +2574,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -1118,6 +2595,15 @@ __metadata: languageName: node linkType: hard +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce + languageName: node + linkType: hard + "es-abstract@npm:^1.22.1, es-abstract@npm:^1.22.3, es-abstract@npm:^1.22.4": version: 1.22.4 resolution: "es-abstract@npm:1.22.4" @@ -1190,6 +2676,23 @@ __metadata: languageName: node linkType: hard +"es-get-iterator@npm:^1.1.3": + version: 1.1.3 + resolution: "es-get-iterator@npm:1.1.3" + dependencies: + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.1.3" + has-symbols: "npm:^1.0.3" + is-arguments: "npm:^1.1.1" + is-map: "npm:^2.0.2" + is-set: "npm:^2.0.2" + is-string: "npm:^1.0.7" + isarray: "npm:^2.0.5" + stop-iteration-iterator: "npm:^1.0.0" + checksum: 10c0/ebd11effa79851ea75d7f079405f9d0dc185559fd65d986c6afea59a0ff2d46c2ed8675f19f03dce7429d7f6c14ff9aede8d121fbab78d75cfda6a263030bac0 + languageName: node + linkType: hard + "es-iterator-helpers@npm:^1.0.12, es-iterator-helpers@npm:^1.0.15": version: 1.0.17 resolution: "es-iterator-helpers@npm:1.0.17" @@ -1251,6 +2754,20 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -1258,13 +2775,31 @@ __metadata: languageName: node linkType: hard -"eslint-config-next@npm:14.1.0": - version: 14.1.0 - resolution: "eslint-config-next@npm:14.1.0" +"escodegen@npm:^2.0.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10c0/e1450a1f75f67d35c061bf0d60888b15f62ab63aef9df1901cffc81cffbbb9e8b3de237c5502cf8613a017c1df3a3003881307c78835a1ab54d8c8d2206e01d3 + languageName: node + linkType: hard + +"eslint-config-next@npm:^14.2.3": + version: 14.2.3 + resolution: "eslint-config-next@npm:14.2.3" dependencies: - "@next/eslint-plugin-next": "npm:14.1.0" + "@next/eslint-plugin-next": "npm:14.2.3" "@rushstack/eslint-patch": "npm:^1.3.3" - "@typescript-eslint/parser": "npm:^5.4.2 || ^6.0.0" + "@typescript-eslint/parser": "npm:^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0" eslint-import-resolver-node: "npm:^0.3.6" eslint-import-resolver-typescript: "npm:^3.5.2" eslint-plugin-import: "npm:^2.28.1" @@ -1277,7 +2812,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/8e3fc5fb99d8d8d03651a44487bd7739fd880cf6698292f548ebdb3886c52b3030758018970401f6220430e7d003a1100a62de47f86f7216d3c86ba0e1cd9cd1 + checksum: 10c0/52a3d48bb783d3e8d76a571a3636f658e4789e1a4a51ebbd14d184b7f6f5dd91281b71d99e49a7bb7e3ab32a2dddd321285110005ca0969a471be5ab2e579887 languageName: node linkType: hard @@ -1497,6 +3032,16 @@ __metadata: languageName: node linkType: hard +"esprima@npm:^4.0.0, esprima@npm:^4.0.1": + version: 4.0.1 + resolution: "esprima@npm:4.0.1" + bin: + esparse: ./bin/esparse.js + esvalidate: ./bin/esvalidate.js + checksum: 10c0/ad4bab9ead0808cf56501750fd9d3fb276f6b105f987707d059005d57e182d18a7c9ec7f3a01794ebddcca676773e42ca48a32d67a250c9d35e009ca613caba3 + languageName: node + linkType: hard + "esquery@npm:^1.4.2": version: 1.5.0 resolution: "esquery@npm:1.5.0" @@ -1529,6 +3074,43 @@ __metadata: languageName: node linkType: hard +"execa@npm:^5.0.0": + version: 5.1.1 + resolution: "execa@npm:5.1.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^6.0.0" + human-signals: "npm:^2.1.0" + is-stream: "npm:^2.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^4.0.1" + onetime: "npm:^5.1.2" + signal-exit: "npm:^3.0.3" + strip-final-newline: "npm:^2.0.0" + checksum: 10c0/c8e615235e8de4c5addf2fa4c3da3e3aa59ce975a3e83533b4f6a71750fb816a2e79610dc5f1799b6e28976c9ae86747a36a606655bf8cb414a74d8d507b304f + languageName: node + linkType: hard + +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10c0/71d2ad9b36bc25bb8b104b17e830b40a08989be7f7d100b13269aaae7c3784c3e6e1e88a797e9e87523993a25ba27c8958959a554535370672cfb4d824af8989 + languageName: node + linkType: hard + +"expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/2eddeace66e68b8d8ee5f7be57f3014b19770caaf6815c7a08d131821da527fb8c8cb7b3dcd7c883d2d3d8d184206a4268984618032d1e4b16dc8d6596475d41 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -1556,7 +3138,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:^2.0.0": +"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b @@ -1579,6 +3161,15 @@ __metadata: languageName: node linkType: hard +"fb-watchman@npm:^2.0.0": + version: 2.0.2 + resolution: "fb-watchman@npm:2.0.2" + dependencies: + bser: "npm:2.1.1" + checksum: 10c0/feae89ac148adb8f6ae8ccd87632e62b13563e6fb114cacb5265c51f585b17e2e268084519fb2edd133872f1d47a18e6bfd7e5e08625c0d41b93149694187581 + languageName: node + linkType: hard + "file-entry-cache@npm:^6.0.1": version: 6.0.1 resolution: "file-entry-cache@npm:6.0.1" @@ -1597,6 +3188,16 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^4.0.0, find-up@npm:^4.1.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/0406ee89ebeefa2d507feb07ec366bebd8a6167ae74aa4e34fb4c4abd06cf782a3ce26ae4194d70706f72182841733f00551c209fe575cb00bd92104056e78c1 + languageName: node + linkType: hard + "find-up@npm:^5.0.0": version: 5.0.0 resolution: "find-up@npm:5.0.0" @@ -1625,6 +3226,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/9ff767f0d7be6aa6870c82ac79cf0368cd73e01bbc00e9eb1c2a16fbb198ec105e3c9b6628bb98e9f3ac66fe29a957b9645bcb9a490bb7aa0d35f908b6b85071 + languageName: node + linkType: hard + "for-each@npm:^0.3.3": version: 0.3.3 resolution: "for-each@npm:0.3.3" @@ -1644,6 +3255,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + "fraction.js@npm:^4.3.7": version: 4.3.7 resolution: "fraction.js@npm:4.3.7" @@ -1656,19 +3278,25 @@ __metadata: resolution: "frontend@workspace:." dependencies: "@sanity/icons": "npm:^2.11.2" + "@testing-library/jest-dom": "npm:^6.4.2" + "@testing-library/react": "npm:^14.2.2" "@types/node": "npm:^20" "@types/react": "npm:^18" "@types/react-dom": "npm:^18" autoprefixer: "npm:^10.0.1" + axios: "npm:^1.6.8" eslint: "npm:^8" - eslint-config-next: "npm:14.1.0" + eslint-config-next: "npm:^14.2.3" eslint-config-prettier: "npm:^9.1.0" - next: "npm:14.1.0" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + next: "npm:^14.2.3" postcss: "npm:^8" prettier: "npm:^3.2.5" - react: "npm:^18" - react-dom: "npm:^18" + react: "npm:^18.3.0" + react-dom: "npm:^18.3.0" tailwindcss: "npm:^3.3.0" + ts-node: "npm:^10.9.2" typescript: "npm:^5" languageName: unknown linkType: soft @@ -1698,7 +3326,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:~2.3.2": +"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -1708,7 +3336,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -1743,6 +3371,20 @@ __metadata: languageName: node linkType: hard +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + "get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.3, get-intrinsic@npm:^1.2.4": version: 1.2.4 resolution: "get-intrinsic@npm:1.2.4" @@ -1756,6 +3398,20 @@ __metadata: languageName: node linkType: hard +"get-package-type@npm:^0.1.0": + version: 0.1.0 + resolution: "get-package-type@npm:0.1.0" + checksum: 10c0/e34cdf447fdf1902a1f6d5af737eaadf606d2ee3518287abde8910e04159368c268568174b2e71102b87b26c2020486f126bfca9c4fb1ceb986ff99b52ecd1be + languageName: node + linkType: hard + +"get-stream@npm:^6.0.0": + version: 6.0.1 + resolution: "get-stream@npm:6.0.1" + checksum: 10c0/49825d57d3fd6964228e6200a58169464b8e8970489b3acdc24906c782fb7f01f9f56f8e6653c4a50713771d6658f7cfe051e5eb8c12e334138c9c918b296341 + languageName: node + linkType: hard + "get-symbol-description@npm:^1.0.2": version: 1.0.2 resolution: "get-symbol-description@npm:1.0.2" @@ -1809,7 +3465,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3": +"glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -1823,6 +3479,13 @@ __metadata: languageName: node linkType: hard +"globals@npm:^11.1.0": + version: 11.12.0 + resolution: "globals@npm:11.12.0" + checksum: 10c0/758f9f258e7b19226bd8d4af5d3b0dcf7038780fb23d82e6f98932c44e239f884847f1766e8fa9cc5635ccb3204f7fa7314d4408dd4002a5e8ea827b4018f0a1 + languageName: node + linkType: hard + "globals@npm:^13.19.0": version: 13.24.0 resolution: "globals@npm:13.24.0" @@ -1864,7 +3527,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -1885,6 +3548,13 @@ __metadata: languageName: node linkType: hard +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 + languageName: node + linkType: hard + "has-flag@npm:^4.0.0": version: 4.0.0 resolution: "has-flag@npm:4.0.0" @@ -1933,6 +3603,22 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" + dependencies: + whatwg-encoding: "npm:^2.0.0" + checksum: 10c0/b17b3b0fb5d061d8eb15121c3b0b536376c3e295ecaf09ba48dd69c6b6c957839db124fe1e2b3f11329753a4ee01aa7dedf63b7677999e86da17fbbdd82c5386 + languageName: node + linkType: hard + +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -1940,6 +3626,17 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/32a05e413430b2c1e542e5c74b38a9f14865301dd69dff2e53ddb684989440e3d2ce0c4b64d25eb63cf6283e6265ff979a61cf93e3ca3d23047ddfdc8df34a32 + languageName: node + linkType: hard + "http-proxy-agent@npm:^7.0.0": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" @@ -1950,6 +3647,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.4 resolution: "https-proxy-agent@npm:7.0.4" @@ -1960,7 +3667,14 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"human-signals@npm:^2.1.0": + version: 2.1.0 + resolution: "human-signals@npm:2.1.0" + checksum: 10c0/695edb3edfcfe9c8b52a76926cd31b36978782062c0ed9b1192b36bebc75c4c87c82e178dfcb0ed0fc27ca59d434198aac0bd0be18f5781ded775604db22304a + languageName: node + linkType: hard + +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -1980,9 +3694,21 @@ __metadata: version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/7f882953aa6b740d1f0e384d0547158bc86efbf2eea0f1483b8900a6f65c5a5123c2cf09b0d542cc419d0b98a759ecaeb394237e97ea427f2da221dc3cd80cc3 + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/7f882953aa6b740d1f0e384d0547158bc86efbf2eea0f1483b8900a6f65c5a5123c2cf09b0d542cc419d0b98a759ecaeb394237e97ea427f2da221dc3cd80cc3 + languageName: node + linkType: hard + +"import-local@npm:^3.0.2": + version: 3.1.0 + resolution: "import-local@npm:3.1.0" + dependencies: + pkg-dir: "npm:^4.2.0" + resolve-cwd: "npm:^3.0.0" + bin: + import-local-fixture: fixtures/cli.js + checksum: 10c0/c67ecea72f775fe8684ca3d057e54bdb2ae28c14bf261d2607c269c18ea0da7b730924c06262eca9aed4b8ab31e31d65bc60b50e7296c85908a56e2f7d41ecd2 languageName: node linkType: hard @@ -2017,7 +3743,7 @@ __metadata: languageName: node linkType: hard -"internal-slot@npm:^1.0.5, internal-slot@npm:^1.0.7": +"internal-slot@npm:^1.0.4, internal-slot@npm:^1.0.5, internal-slot@npm:^1.0.7": version: 1.0.7 resolution: "internal-slot@npm:1.0.7" dependencies: @@ -2038,7 +3764,17 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.4": +"is-arguments@npm:^1.1.1": + version: 1.1.1 + resolution: "is-arguments@npm:1.1.1" + dependencies: + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: 10c0/5ff1f341ee4475350adfc14b2328b38962564b7c2076be2f5bac7bd9b61779efba99b9f844a7b82ba7654adccf8e8eb19d1bb0cc6d1c1a085e498f6793d4328f + languageName: node + linkType: hard + +"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4": version: 3.0.4 resolution: "is-array-buffer@npm:3.0.4" dependencies: @@ -2048,6 +3784,13 @@ __metadata: languageName: node linkType: hard +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + "is-async-function@npm:^2.0.0": version: 2.0.0 resolution: "is-async-function@npm:2.0.0" @@ -2133,6 +3876,13 @@ __metadata: languageName: node linkType: hard +"is-generator-fn@npm:^2.0.0": + version: 2.1.0 + resolution: "is-generator-fn@npm:2.1.0" + checksum: 10c0/2957cab387997a466cd0bf5c1b6047bd21ecb32bdcfd8996b15747aa01002c1c88731802f1b3d34ac99f4f6874b626418bd118658cf39380fe5fff32a3af9c4d + languageName: node + linkType: hard + "is-generator-function@npm:^1.0.10": version: 1.0.10 resolution: "is-generator-function@npm:1.0.10" @@ -2165,6 +3915,13 @@ __metadata: languageName: node linkType: hard +"is-map@npm:^2.0.2": + version: 2.0.3 + resolution: "is-map@npm:2.0.3" + checksum: 10c0/2c4d431b74e00fdda7162cd8e4b763d6f6f217edf97d4f8538b94b8702b150610e2c64961340015fe8df5b1fcee33ccd2e9b62619c4a8a3a155f8de6d6d355fc + languageName: node + linkType: hard + "is-negative-zero@npm:^2.0.2": version: 2.0.3 resolution: "is-negative-zero@npm:2.0.3" @@ -2181,143 +3938,689 @@ __metadata: languageName: node linkType: hard -"is-number@npm:^7.0.0": - version: 7.0.0 - resolution: "is-number@npm:7.0.0" - checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-path-inside@npm:^3.0.3": + version: 3.0.3 + resolution: "is-path-inside@npm:3.0.3" + checksum: 10c0/cf7d4ac35fb96bab6a1d2c3598fe5ebb29aafb52c0aaa482b5a3ed9d8ba3edc11631e3ec2637660c44b3ce0e61a08d54946e8af30dec0b60a7c27296c68ffd05 + languageName: node + linkType: hard + +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + +"is-regex@npm:^1.1.4": + version: 1.1.4 + resolution: "is-regex@npm:1.1.4" + dependencies: + call-bind: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.0" + checksum: 10c0/bb72aae604a69eafd4a82a93002058c416ace8cde95873589a97fc5dac96a6c6c78a9977d487b7b95426a8f5073969124dd228f043f9f604f041f32fcc465fc1 + languageName: node + linkType: hard + +"is-set@npm:^2.0.1": + version: 2.0.2 + resolution: "is-set@npm:2.0.2" + checksum: 10c0/5f8bd1880df8c0004ce694e315e6e1e47a3452014be792880bb274a3b2cdb952fdb60789636ca6e084c7947ca8b7ae03ccaf54c93a7fcfed228af810559e5432 + languageName: node + linkType: hard + +"is-set@npm:^2.0.2": + version: 2.0.3 + resolution: "is-set@npm:2.0.3" + checksum: 10c0/f73732e13f099b2dc879c2a12341cfc22ccaca8dd504e6edae26484bd5707a35d503fba5b4daad530a9b088ced1ae6c9d8200fd92e09b428fe14ea79ce8080b7 + languageName: node + linkType: hard + +"is-shared-array-buffer@npm:^1.0.2": + version: 1.0.3 + resolution: "is-shared-array-buffer@npm:1.0.3" + dependencies: + call-bind: "npm:^1.0.7" + checksum: 10c0/adc11ab0acbc934a7b9e5e9d6c588d4ec6682f6fea8cda5180721704fa32927582ede5b123349e32517fdadd07958973d24716c80e7ab198970c47acc09e59c7 + languageName: node + linkType: hard + +"is-stream@npm:^2.0.0": + version: 2.0.1 + resolution: "is-stream@npm:2.0.1" + checksum: 10c0/7c284241313fc6efc329b8d7f08e16c0efeb6baab1b4cd0ba579eb78e5af1aa5da11e68559896a2067cd6c526bd29241dda4eb1225e627d5aa1a89a76d4635a5 + languageName: node + linkType: hard + +"is-string@npm:^1.0.5, is-string@npm:^1.0.7": + version: 1.0.7 + resolution: "is-string@npm:1.0.7" + dependencies: + has-tostringtag: "npm:^1.0.0" + checksum: 10c0/905f805cbc6eedfa678aaa103ab7f626aac9ebbdc8737abb5243acaa61d9820f8edc5819106b8fcd1839e33db21de9f0116ae20de380c8382d16dc2a601921f6 + languageName: node + linkType: hard + +"is-symbol@npm:^1.0.2, is-symbol@npm:^1.0.3": + version: 1.0.4 + resolution: "is-symbol@npm:1.0.4" + dependencies: + has-symbols: "npm:^1.0.2" + checksum: 10c0/9381dd015f7c8906154dbcbf93fad769de16b4b961edc94f88d26eb8c555935caa23af88bda0c93a18e65560f6d7cca0fd5a3f8a8e1df6f1abbb9bead4502ef7 + languageName: node + linkType: hard + +"is-typed-array@npm:^1.1.13": + version: 1.1.13 + resolution: "is-typed-array@npm:1.1.13" + dependencies: + which-typed-array: "npm:^1.1.14" + checksum: 10c0/fa5cb97d4a80e52c2cc8ed3778e39f175a1a2ae4ddf3adae3187d69586a1fd57cfa0b095db31f66aa90331e9e3da79184cea9c6abdcd1abc722dc3c3edd51cca + languageName: node + linkType: hard + +"is-weakmap@npm:^2.0.1": + version: 2.0.1 + resolution: "is-weakmap@npm:2.0.1" + checksum: 10c0/9c9fec9efa7bf5030a4a927f33fff2a6976b93646259f92b517d3646c073cc5b98283a162ce75c412b060a46de07032444b530f0a4c9b6e012ef8f1741c3a987 + languageName: node + linkType: hard + +"is-weakref@npm:^1.0.2": + version: 1.0.2 + resolution: "is-weakref@npm:1.0.2" + dependencies: + call-bind: "npm:^1.0.2" + checksum: 10c0/1545c5d172cb690c392f2136c23eec07d8d78a7f57d0e41f10078aa4f5daf5d7f57b6513a67514ab4f073275ad00c9822fc8935e00229d0a2089e1c02685d4b1 + languageName: node + linkType: hard + +"is-weakset@npm:^2.0.1": + version: 2.0.2 + resolution: "is-weakset@npm:2.0.2" + dependencies: + call-bind: "npm:^1.0.2" + get-intrinsic: "npm:^1.1.1" + checksum: 10c0/ef5136bd446ae4603229b897f73efd0720c6ab3ec6cc05c8d5c4b51aa9f95164713c4cad0a22ff1fedf04865ff86cae4648bc1d5eead4b6388e1150525af1cc1 + languageName: node + linkType: hard + +"isarray@npm:^2.0.5": + version: 2.0.5 + resolution: "isarray@npm:2.0.5" + checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd + languageName: node + linkType: hard + +"isexe@npm:^2.0.0": + version: 2.0.0 + resolution: "isexe@npm:2.0.0" + checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d + languageName: node + linkType: hard + +"isexe@npm:^3.1.1": + version: 3.1.1 + resolution: "isexe@npm:3.1.1" + checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 + languageName: node + linkType: hard + +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": "npm:^7.12.3" + "@babel/parser": "npm:^7.14.7" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^6.3.0" + checksum: 10c0/8a1bdf3e377dcc0d33ec32fe2b6ecacdb1e4358fd0eb923d4326bb11c67622c0ceb99600a680f3dad5d29c66fc1991306081e339b4d43d0b8a2ab2e1d910a6ee + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.2 + resolution: "istanbul-lib-instrument@npm:6.0.2" + dependencies: + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10c0/405c6ac037bf8c7ee7495980b0cd5544b2c53078c10534d0c9ceeb92a9ea7dcf8510f58ccfce31336458a8fa6ccef27b570bbb602abaa8c1650f5496a807477c + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 10c0/19e4cc405016f2c906dff271a76715b3e881fa9faeb3f09a86cb99b8512b3a5ed19cadfe0b54c17ca0e54c1142c9c6de9330d65506e35873994e06634eebeb66 + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.3": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + +"iterator.prototype@npm:^1.1.2": + version: 1.1.2 + resolution: "iterator.prototype@npm:1.1.2" + dependencies: + define-properties: "npm:^1.2.1" + get-intrinsic: "npm:^1.2.1" + has-symbols: "npm:^1.0.3" + reflect.getprototypeof: "npm:^1.0.4" + set-function-name: "npm:^2.0.1" + checksum: 10c0/a32151326095e916f306990d909f6bbf23e3221999a18ba686419535dcd1749b10ded505e89334b77dc4c7a58a8508978f0eb16c2c8573e6d412eb7eb894ea79 + languageName: node + linkType: hard + +"jackspeak@npm:^2.3.5": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10c0/f01d8f972d894cd7638bc338e9ef5ddb86f7b208ce177a36d718eac96ec86638a6efa17d0221b10073e64b45edc2ce15340db9380b1f5d5c5d000cbc517dc111 + languageName: node + linkType: hard + +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10c0/e071384d9e2f6bb462231ac53f29bff86f0e12394c1b49ccafbad225ce2ab7da226279a8a94f421949920bef9be7ef574fd86aee22e8adfa149be73554ab828b + languageName: node + linkType: hard + +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10c0/8d15344cf7a9f14e926f0deed64ed190c7a4fa1ed1acfcd81e4cc094d3cc5bf7902ebb7b874edc98ada4185688f90c91e1747e0dfd7ac12463b097968ae74b5e + languageName: node + linkType: hard + +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10c0/a658fd55050d4075d65c1066364595962ead7661711495cfa1dfeecf3d6d0a8ffec532f3dbd8afbb3e172dd5fd2fb2e813c5e10256e7cf2fea766314942fb43a + languageName: node + linkType: hard + +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + ts-node: + optional: true + checksum: 10c0/bab23c2eda1fff06e0d104b00d6adfb1d1aabb7128441899c9bff2247bd26710b050a5364281ce8d52b46b499153bf7e3ee88b19831a8f3451f1477a0246a0f1 + languageName: node + linkType: hard + +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/89a4a7f182590f56f526443dde69acefb1f2f0c9e59253c61d319569856c4931eae66b8a3790c443f529267a0ddba5ba80431c585deed81827032b2b2a1fc999 + languageName: node + linkType: hard + +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: "npm:^3.0.0" + checksum: 10c0/d932a8272345cf6b6142bb70a2bb63e0856cc0093f082821577ea5bdf4643916a98744dfc992189d2b1417c38a11fa42466f6111526bc1fb81366f56410f3be9 + languageName: node + linkType: hard + +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10c0/f7f9a90ebee80cc688e825feceb2613627826ac41ea76a366fa58e669c3b2403d364c7c0a74d862d469b103c843154f8456d3b1c02b487509a12afa8b59edbb4 + languageName: node + linkType: hard + +"jest-environment-jsdom@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/139b94e2c8ec1bb5a46ce17df5211da65ce867354b3fd4e00fa6a0d1da95902df4cf7881273fc6ea937e5c325d39d6773f0d41b6c469363334de9d489d2c321f + languageName: node + linkType: hard + +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10c0/61f04fec077f8b1b5c1a633e3612fc0c9aa79a0ab7b05600683428f1e01a4d35346c474bde6f439f9fcc1a4aa9a2861ff852d079a43ab64b02105d1004b2592b languageName: node linkType: hard -"is-path-inside@npm:^3.0.3": - version: 3.0.3 - resolution: "is-path-inside@npm:3.0.3" - checksum: 10c0/cf7d4ac35fb96bab6a1d2c3598fe5ebb29aafb52c0aaa482b5a3ed9d8ba3edc11631e3ec2637660c44b3ce0e61a08d54946e8af30dec0b60a7c27296c68ffd05 +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10c0/552e7a97a983d3c2d4e412a44eb7de0430ff773dd99f7500962c268d6dfbfa431d7d08f919c9d960530e5f7f78eb47f267ad9b318265e5092b3ff9ede0db7c2b languageName: node linkType: hard -"is-regex@npm:^1.1.4": - version: 1.1.4 - resolution: "is-regex@npm:1.1.4" +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" dependencies: - call-bind: "npm:^1.0.2" - has-tostringtag: "npm:^1.0.0" - checksum: 10c0/bb72aae604a69eafd4a82a93002058c416ace8cde95873589a97fc5dac96a6c6c78a9977d487b7b95426a8f5073969124dd228f043f9f604f041f32fcc465fc1 + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" + "@types/node": "npm:*" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.3.2" + graceful-fs: "npm:^4.2.9" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10c0/2683a8f29793c75a4728787662972fedd9267704c8f7ef9d84f2beed9a977f1cf5e998c07b6f36ba5603f53cb010c911fe8cd0ac9886e073fe28ca66beefd30c languageName: node linkType: hard -"is-set@npm:^2.0.1": - version: 2.0.2 - resolution: "is-set@npm:2.0.2" - checksum: 10c0/5f8bd1880df8c0004ce694e315e6e1e47a3452014be792880bb274a3b2cdb952fdb60789636ca6e084c7947ca8b7ae03ccaf54c93a7fcfed228af810559e5432 +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/71bb9f77fc489acb842a5c7be030f2b9acb18574dc9fb98b3100fc57d422b1abc55f08040884bd6e6dbf455047a62f7eaff12aa4058f7cbdc11558718ca6a395 languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2": - version: 1.0.3 - resolution: "is-shared-array-buffer@npm:1.0.3" +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" dependencies: - call-bind: "npm:^1.0.7" - checksum: 10c0/adc11ab0acbc934a7b9e5e9d6c588d4ec6682f6fea8cda5180721704fa32927582ede5b123349e32517fdadd07958973d24716c80e7ab198970c47acc09e59c7 + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10c0/0d0e70b28fa5c7d4dce701dc1f46ae0922102aadc24ed45d594dd9b7ae0a8a6ef8b216718d1ab79e451291217e05d4d49a82666e1a3cc2b428b75cd9c933244e languageName: node linkType: hard -"is-string@npm:^1.0.5, is-string@npm:^1.0.7": - version: 1.0.7 - resolution: "is-string@npm:1.0.7" +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" dependencies: - has-tostringtag: "npm:^1.0.0" - checksum: 10c0/905f805cbc6eedfa678aaa103ab7f626aac9ebbdc8737abb5243acaa61d9820f8edc5819106b8fcd1839e33db21de9f0116ae20de380c8382d16dc2a601921f6 + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10c0/850ae35477f59f3e6f27efac5215f706296e2104af39232bb14e5403e067992afb5c015e87a9243ec4d9df38525ef1ca663af9f2f4766aa116f127247008bd22 languageName: node linkType: hard -"is-symbol@npm:^1.0.2, is-symbol@npm:^1.0.3": - version: 1.0.4 - resolution: "is-symbol@npm:1.0.4" +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" dependencies: - has-symbols: "npm:^1.0.2" - checksum: 10c0/9381dd015f7c8906154dbcbf93fad769de16b4b961edc94f88d26eb8c555935caa23af88bda0c93a18e65560f6d7cca0fd5a3f8a8e1df6f1abbb9bead4502ef7 + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + checksum: 10c0/7b9f8349ee87695a309fe15c46a74ab04c853369e5c40952d68061d9dc3159a0f0ed73e215f81b07ee97a9faaf10aebe5877a9d6255068a0977eae6a9ff1d5ac languageName: node linkType: hard -"is-typed-array@npm:^1.1.13": - version: 1.1.13 - resolution: "is-typed-array@npm:1.1.13" +"jest-pnp-resolver@npm:^1.2.2": + version: 1.2.3 + resolution: "jest-pnp-resolver@npm:1.2.3" + peerDependencies: + jest-resolve: "*" + peerDependenciesMeta: + jest-resolve: + optional: true + checksum: 10c0/86eec0c78449a2de733a6d3e316d49461af6a858070e113c97f75fb742a48c2396ea94150cbca44159ffd4a959f743a47a8b37a792ef6fdad2cf0a5cba973fac + languageName: node + linkType: hard + +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 10c0/4e33fb16c4f42111159cafe26397118dcfc4cf08bc178a67149fb05f45546a91928b820894572679d62559839d0992e21080a1527faad65daaae8743a5705a3b + languageName: node + linkType: hard + +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" dependencies: - which-typed-array: "npm:^1.1.14" - checksum: 10c0/fa5cb97d4a80e52c2cc8ed3778e39f175a1a2ae4ddf3adae3187d69586a1fd57cfa0b095db31f66aa90331e9e3da79184cea9c6abdcd1abc722dc3c3edd51cca + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10c0/b6e9ad8ae5b6049474118ea6441dfddd385b6d1fc471db0136f7c8fbcfe97137a9665e4f837a9f49f15a29a1deb95a14439b7aec812f3f99d08f228464930f0d languageName: node linkType: hard -"is-weakmap@npm:^2.0.1": - version: 2.0.1 - resolution: "is-weakmap@npm:2.0.1" - checksum: 10c0/9c9fec9efa7bf5030a4a927f33fff2a6976b93646259f92b517d3646c073cc5b98283a162ce75c412b060a46de07032444b530f0a4c9b6e012ef8f1741c3a987 +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: 10c0/59da5c9c5b50563e959a45e09e2eace783d7f9ac0b5dcc6375dea4c0db938d2ebda97124c8161310082760e8ebbeff9f6b177c15ca2f57fb424f637a5d2adb47 languageName: node linkType: hard -"is-weakref@npm:^1.0.2": - version: 1.0.2 - resolution: "is-weakref@npm:1.0.2" +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" dependencies: - call-bind: "npm:^1.0.2" - checksum: 10c0/1545c5d172cb690c392f2136c23eec07d8d78a7f57d0e41f10078aa4f5daf5d7f57b6513a67514ab4f073275ad00c9822fc8935e00229d0a2089e1c02685d4b1 + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10c0/2194b4531068d939f14c8d3274fe5938b77fa73126aedf9c09ec9dec57d13f22c72a3b5af01ac04f5c1cf2e28d0ac0b4a54212a61b05f10b5d6b47f2a1097bb4 + languageName: node + linkType: hard + +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10c0/7cd89a1deda0bda7d0941835434e44f9d6b7bd50b5c5d9b0fc9a6c990b2d4d2cab59685ab3cb2850ed4cc37059f6de903af5a50565d7f7f1192a77d3fd6dd2a6 languageName: node linkType: hard -"is-weakset@npm:^2.0.1": - version: 2.0.2 - resolution: "is-weakset@npm:2.0.2" +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" dependencies: - call-bind: "npm:^1.0.2" - get-intrinsic: "npm:^1.1.1" - checksum: 10c0/ef5136bd446ae4603229b897f73efd0720c6ab3ec6cc05c8d5c4b51aa9f95164713c4cad0a22ff1fedf04865ff86cae4648bc1d5eead4b6388e1150525af1cc1 + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10c0/6e9003c94ec58172b4a62864a91c0146513207bedf4e0a06e1e2ac70a4484088a2683e3a0538d8ea913bcfd53dc54a9b98a98cdfa562e7fe1d1339aeae1da570 languageName: node linkType: hard -"isarray@npm:^2.0.5": - version: 2.0.5 - resolution: "isarray@npm:2.0.5" - checksum: 10c0/4199f14a7a13da2177c66c31080008b7124331956f47bca57dd0b6ea9f11687aa25e565a2c7a2b519bc86988d10398e3049a1f5df13c9f6b7664154690ae79fd +"jest-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-util@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + graceful-fs: "npm:^4.2.9" + picomatch: "npm:^2.2.3" + checksum: 10c0/bc55a8f49fdbb8f51baf31d2a4f312fb66c9db1483b82f602c9c990e659cdd7ec529c8e916d5a89452ecbcfae4949b21b40a7a59d4ffc0cd813a973ab08c8150 languageName: node linkType: hard -"isexe@npm:^2.0.0": - version: 2.0.0 - resolution: "isexe@npm:2.0.0" - checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + leven: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + checksum: 10c0/a20b930480c1ed68778c739f4739dce39423131bc070cd2505ddede762a5570a256212e9c2401b7ae9ba4d7b7c0803f03c5b8f1561c62348213aba18d9dbece2 languageName: node linkType: hard -"isexe@npm:^3.1.1": - version: 3.1.1 - resolution: "isexe@npm:3.1.1" - checksum: 10c0/9ec257654093443eb0a528a9c8cbba9c0ca7616ccb40abd6dde7202734d96bb86e4ac0d764f0f8cd965856aacbff2f4ce23e730dc19dfb41e3b0d865ca6fdcc7 +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10c0/ec6c75030562fc8f8c727cb8f3b94e75d831fc718785abfc196e1f2a2ebc9a2e38744a15147170039628a853d77a3b695561ce850375ede3a4ee6037a2574567 languageName: node linkType: hard -"iterator.prototype@npm:^1.1.2": - version: 1.1.2 - resolution: "iterator.prototype@npm:1.1.2" +"jest-worker@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-worker@npm:29.7.0" dependencies: - define-properties: "npm:^1.2.1" - get-intrinsic: "npm:^1.2.1" - has-symbols: "npm:^1.0.3" - reflect.getprototypeof: "npm:^1.0.4" - set-function-name: "npm:^2.0.1" - checksum: 10c0/a32151326095e916f306990d909f6bbf23e3221999a18ba686419535dcd1749b10ded505e89334b77dc4c7a58a8508978f0eb16c2c8573e6d412eb7eb894ea79 + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.0.0" + checksum: 10c0/5570a3a005b16f46c131968b8a5b56d291f9bbb85ff4217e31c80bd8a02e7de799e59a54b95ca28d5c302f248b54cbffde2d177c2f0f52ffcee7504c6eabf660 languageName: node linkType: hard -"jackspeak@npm:^2.3.5": - version: 2.3.6 - resolution: "jackspeak@npm:2.3.6" +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" dependencies: - "@isaacs/cliui": "npm:^8.0.2" - "@pkgjs/parseargs": "npm:^0.11.0" - dependenciesMeta: - "@pkgjs/parseargs": + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: optional: true - checksum: 10c0/f01d8f972d894cd7638bc338e9ef5ddb86f7b208ce177a36d718eac96ec86638a6efa17d0221b10073e64b45edc2ce15340db9380b1f5d5c5d000cbc517dc111 + bin: + jest: bin/jest.js + checksum: 10c0/f40eb8171cf147c617cc6ada49d062fbb03b4da666cb8d39cdbfb739a7d75eea4c3ca150fb072d0d273dce0c753db4d0467d54906ad0293f59c54f9db4a09d8b languageName: node linkType: hard @@ -2330,13 +4633,25 @@ __metadata: languageName: node linkType: hard -"js-tokens@npm:^3.0.0 || ^4.0.0": +"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed languageName: node linkType: hard +"js-yaml@npm:^3.13.1": + version: 3.14.1 + resolution: "js-yaml@npm:3.14.1" + dependencies: + argparse: "npm:^1.0.7" + esprima: "npm:^4.0.0" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/6746baaaeac312c4db8e75fa22331d9a04cccb7792d126ed8ce6a0bbcfef0cedaddd0c5098fade53db067c09fe00aa1c957674b4765610a8b06a5a189e46433b + languageName: node + linkType: hard + "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -2355,6 +4670,54 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" + dependencies: + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/b109073bb826a966db7828f46cb1d7371abecd30f182b143c52be5fe1ed84513bbbe995eb3d157241681fcd18331381e61e3dc004d4949f3a63bca02f6214902 + languageName: node + linkType: hard + +"jsesc@npm:^2.5.1": + version: 2.5.2 + resolution: "jsesc@npm:2.5.2" + bin: + jsesc: bin/jsesc + checksum: 10c0/dbf59312e0ebf2b4405ef413ec2b25abb5f8f4d9bc5fb8d9f90381622ebca5f2af6a6aa9a8578f65903f9e33990a6dc798edd0ce5586894bf0e9e31803a1de88 + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -2362,6 +4725,13 @@ __metadata: languageName: node linkType: hard +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -2387,6 +4757,15 @@ __metadata: languageName: node linkType: hard +"json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -2408,6 +4787,13 @@ __metadata: languageName: node linkType: hard +"kleur@npm:^3.0.3": + version: 3.0.3 + resolution: "kleur@npm:3.0.3" + checksum: 10c0/cd3a0b8878e7d6d3799e54340efe3591ca787d9f95f109f28129bdd2915e37807bf8918bb295ab86afb8c82196beec5a1adcaf29042ce3f2bd932b038fe3aa4b + languageName: node + linkType: hard + "language-subtag-registry@npm:^0.3.20": version: 0.3.22 resolution: "language-subtag-registry@npm:0.3.22" @@ -2424,6 +4810,13 @@ __metadata: languageName: node linkType: hard +"leven@npm:^3.1.0": + version: 3.1.0 + resolution: "leven@npm:3.1.0" + checksum: 10c0/cd778ba3fbab0f4d0500b7e87d1f6e1f041507c56fdcd47e8256a3012c98aaee371d4c15e0a76e0386107af2d42e2b7466160a2d80688aaa03e66e49949f42df + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -2455,6 +4848,15 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: "npm:^4.1.0" + checksum: 10c0/33a1c5247e87e022f9713e6213a744557a3e9ec32c5d0b5efb10aa3a38177615bf90221a5592674857039c1a0fd2063b82f285702d37b792d973e9e72ace6c59 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -2471,6 +4873,13 @@ __metadata: languageName: node linkType: hard +"lodash@npm:^4.17.15": + version: 4.17.21 + resolution: "lodash@npm:4.17.21" + checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c + languageName: node + linkType: hard + "loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0": version: 1.4.0 resolution: "loose-envify@npm:1.4.0" @@ -2489,6 +4898,15 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: "npm:^3.0.2" + checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 + languageName: node + linkType: hard + "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -2498,6 +4916,31 @@ __metadata: languageName: node linkType: hard +"lz-string@npm:^1.5.0": + version: 1.5.0 + resolution: "lz-string@npm:1.5.0" + bin: + lz-string: bin/bin.js + checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b + languageName: node + linkType: hard + +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + +"make-error@npm:^1.1.1": + version: 1.3.6 + resolution: "make-error@npm:1.3.6" + checksum: 10c0/171e458d86854c6b3fc46610cfacf0b45149ba043782558c6875d9f42f222124384ad0b468c92e996d815a8a2003817a710c0a160e49c1c394626f76fa45396f + languageName: node + linkType: hard + "make-fetch-happen@npm:^13.0.0": version: 13.0.0 resolution: "make-fetch-happen@npm:13.0.0" @@ -2517,6 +4960,22 @@ __metadata: languageName: node linkType: hard +"makeerror@npm:1.0.12": + version: 1.0.12 + resolution: "makeerror@npm:1.0.12" + dependencies: + tmpl: "npm:1.0.5" + checksum: 10c0/b0e6e599780ce6bab49cc413eba822f7d1f0dfebd1c103eaa3785c59e43e22c59018323cf9e1708f0ef5329e94a745d163fcbb6bff8e4c6742f9be9e86f3500c + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -2534,6 +4993,36 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: 10c0/b26f5479d7ec6cc2bce275a08f146cf78f5e7b661b18114e2506dd91ec7ec47e7a25bf4360e5438094db0560bcc868079fb3b1fb3892b833c1ecbf63f80c95a4 + languageName: node + linkType: hard + +"min-indent@npm:^1.0.0": + version: 1.0.1 + resolution: "min-indent@npm:1.0.1" + checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c + languageName: node + linkType: hard + "minimatch@npm:9.0.3, minimatch@npm:^9.0.1": version: 9.0.3 resolution: "minimatch@npm:9.0.3" @@ -2543,7 +5032,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -2700,21 +5189,21 @@ __metadata: languageName: node linkType: hard -"next@npm:14.1.0": - version: 14.1.0 - resolution: "next@npm:14.1.0" +"next@npm:^14.2.3": + version: 14.2.3 + resolution: "next@npm:14.2.3" dependencies: - "@next/env": "npm:14.1.0" - "@next/swc-darwin-arm64": "npm:14.1.0" - "@next/swc-darwin-x64": "npm:14.1.0" - "@next/swc-linux-arm64-gnu": "npm:14.1.0" - "@next/swc-linux-arm64-musl": "npm:14.1.0" - "@next/swc-linux-x64-gnu": "npm:14.1.0" - "@next/swc-linux-x64-musl": "npm:14.1.0" - "@next/swc-win32-arm64-msvc": "npm:14.1.0" - "@next/swc-win32-ia32-msvc": "npm:14.1.0" - "@next/swc-win32-x64-msvc": "npm:14.1.0" - "@swc/helpers": "npm:0.5.2" + "@next/env": "npm:14.2.3" + "@next/swc-darwin-arm64": "npm:14.2.3" + "@next/swc-darwin-x64": "npm:14.2.3" + "@next/swc-linux-arm64-gnu": "npm:14.2.3" + "@next/swc-linux-arm64-musl": "npm:14.2.3" + "@next/swc-linux-x64-gnu": "npm:14.2.3" + "@next/swc-linux-x64-musl": "npm:14.2.3" + "@next/swc-win32-arm64-msvc": "npm:14.2.3" + "@next/swc-win32-ia32-msvc": "npm:14.2.3" + "@next/swc-win32-x64-msvc": "npm:14.2.3" + "@swc/helpers": "npm:0.5.5" busboy: "npm:1.6.0" caniuse-lite: "npm:^1.0.30001579" graceful-fs: "npm:^4.2.11" @@ -2722,6 +5211,7 @@ __metadata: styled-jsx: "npm:5.1.1" peerDependencies: "@opentelemetry/api": ^1.1.0 + "@playwright/test": ^1.41.2 react: ^18.2.0 react-dom: ^18.2.0 sass: ^1.3.0 @@ -2747,11 +5237,13 @@ __metadata: peerDependenciesMeta: "@opentelemetry/api": optional: true + "@playwright/test": + optional: true sass: optional: true bin: next: dist/bin/next - checksum: 10c0/dbb1ef8d22eec29a9127d28ed46eb34f14e3f7f7b4e4b91dc96027feb4d9ead554a804275484d9a54026e6e55d632d3997561e598c1fb8e8956e77614f39765f + checksum: 10c0/2c409154720846d07a7a995cc3bfba24b9ee73c87360ce3266528c8a217f5f1ab6f916cffbe1be83509b4e8d7b1d713921bb5c69338b4ecaa57df3212f79a8c5 languageName: node linkType: hard @@ -2775,6 +5267,13 @@ __metadata: languageName: node linkType: hard +"node-int64@npm:^0.4.0": + version: 0.4.0 + resolution: "node-int64@npm:0.4.0" + checksum: 10c0/a6a4d8369e2f2720e9c645255ffde909c0fbd41c92ea92a5607fc17055955daac99c1ff589d421eee12a0d24e99f7bfc2aabfeb1a4c14742f6c099a51863f31a + languageName: node + linkType: hard + "node-releases@npm:^2.0.14": version: 2.0.14 resolution: "node-releases@npm:2.0.14" @@ -2807,6 +5306,22 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^4.0.1": + version: 4.0.1 + resolution: "npm-run-path@npm:4.0.1" + dependencies: + path-key: "npm:^3.0.0" + checksum: 10c0/6f9353a95288f8455cf64cbeb707b28826a7f29690244c1e4bb61ec573256e021b6ad6651b394eb1ccfd00d6ec50147253aba2c5fe58a57ceb111fad62c519ac + languageName: node + linkType: hard + +"nwsapi@npm:^2.2.2": + version: 2.2.7 + resolution: "nwsapi@npm:2.2.7" + checksum: 10c0/44be198adae99208487a1c886c0a3712264f7bbafa44368ad96c003512fed2753d4e22890ca1e6edb2690c3456a169f2a3c33bfacde1905cf3bf01c7722464db + languageName: node + linkType: hard + "object-assign@npm:^4.0.1, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -2828,6 +5343,16 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0 + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -2912,6 +5437,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^5.1.2": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: "npm:^2.1.0" + checksum: 10c0/ffcef6fbb2692c3c40749f31ea2e22677a876daea92959b8a80b521d95cca7a668c884d8b2045d1d8ee7d56796aa405c405462af112a1477594cc63531baeb8f + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -2926,7 +5460,16 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: "npm:^2.0.0" + checksum: 10c0/8da01ac53efe6a627080fafc127c873da40c18d87b3f5d5492d465bb85ec7207e153948df6b9cbaeb130be70152f874229b8242ee2be84c0794082510af97f12 + languageName: node + linkType: hard + +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -2935,6 +5478,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: "npm:^2.2.0" + checksum: 10c0/1b476ad69ad7f6059744f343b26d51ce091508935c1dbb80c4e0a2f397ffce0ca3a1f9f5cd3c7ce19d7929a09719d5c65fe70d8ee289c3f267cd36f2881813e9 + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -2953,6 +5505,13 @@ __metadata: languageName: node linkType: hard +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: 10c0/c36c19907734c904b16994e6535b02c36c2224d433e01a2f1ab777237f4d86e6289fd5fd464850491e940379d4606ed850c03e0f9ab600b0ebddb511312e177f + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -2962,6 +5521,27 @@ __metadata: languageName: node linkType: hard +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + +"parse5@npm:^7.0.0, parse5@npm:^7.1.1": + version: 7.1.2 + resolution: "parse5@npm:7.1.2" + dependencies: + entities: "npm:^4.4.0" + checksum: 10c0/297d7af8224f4b5cb7f6617ecdae98eeaed7f8cbd78956c42785e230505d5a4f07cef352af10d3006fa5c1544b76b57784d3a22d861ae071bbc460c649482bf4 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -2976,7 +5556,7 @@ __metadata: languageName: node linkType: hard -"path-key@npm:^3.1.0": +"path-key@npm:^3.0.0, path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c @@ -3014,7 +5594,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be @@ -3028,13 +5608,22 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.1": +"pirates@npm:^4.0.1, pirates@npm:^4.0.4": version: 4.0.6 resolution: "pirates@npm:4.0.6" checksum: 10c0/00d5fa51f8dded94d7429700fb91a0c1ead00ae2c7fd27089f0c5b63e6eca36197fe46384631872690a66f390c5e27198e99006ab77ae472692ab9c2ca903f36 languageName: node linkType: hard +"pkg-dir@npm:^4.2.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: "npm:^4.0.0" + checksum: 10c0/c56bda7769e04907a88423feb320babaed0711af8c436ce3e56763ab1021ba107c7b0cafb11cde7529f669cfc22bffcaebffb573645cbd63842ea9fb17cd7728 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0" @@ -3150,6 +5739,28 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.2": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10c0/0cbda1031aa30c659e10921fa94e0dd3f903ecbbbe7184a729ad66f2b6e7f17891e8c7d7654c458fa4ccb1a411ffb695b4f17bbcd3fe075fabe181027c4040ed + languageName: node + linkType: hard + +"pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f + languageName: node + linkType: hard + "proc-log@npm:^3.0.0": version: 3.0.0 resolution: "proc-log@npm:3.0.0" @@ -3167,6 +5778,16 @@ __metadata: languageName: node linkType: hard +"prompts@npm:^2.0.1": + version: 2.4.2 + resolution: "prompts@npm:2.4.2" + dependencies: + kleur: "npm:^3.0.3" + sisteransi: "npm:^1.0.5" + checksum: 10c0/16f1ac2977b19fe2cf53f8411cc98db7a3c8b115c479b2ca5c82b5527cd937aa405fa04f9a5960abeb9daef53191b53b4d13e35c1f5d50e8718c76917c5f1ea4 + languageName: node + linkType: hard + "prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" @@ -3178,13 +5799,41 @@ __metadata: languageName: node linkType: hard -"punycode@npm:^2.1.0": +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b + languageName: node + linkType: hard + +"psl@npm:^1.1.33": + version: 1.9.0 + resolution: "psl@npm:1.9.0" + checksum: 10c0/6a3f805fdab9442f44de4ba23880c4eba26b20c8e8e0830eff1cb31007f6825dace61d17203c58bfe36946842140c97a1ba7f67bc63ca2d88a7ee052b65d97ab + languageName: node + linkType: hard + +"punycode@npm:^2.1.0, punycode@npm:^2.1.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10c0/1abe217897bf74dcb3a0c9aba3555fe975023147b48db540aa2faf507aee91c03bf54f6aef0eb2bf59cc259a16d06b28eca37f0dc426d94f4692aeff02fb0e65 + languageName: node + linkType: hard + +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -3192,15 +5841,15 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^18": - version: 18.2.0 - resolution: "react-dom@npm:18.2.0" +"react-dom@npm:^18.3.0": + version: 18.3.0 + resolution: "react-dom@npm:18.3.0" dependencies: loose-envify: "npm:^1.1.0" - scheduler: "npm:^0.23.0" + scheduler: "npm:^0.23.1" peerDependencies: - react: ^18.2.0 - checksum: 10c0/66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a + react: ^18.3.0 + checksum: 10c0/5072767a5d67e242579e5ed46094bf5665385fcfc50584e818273ba668f768348bfd9101841fa3986635635b1238a7a5b2d28b73b134ebbe58a415311afd60d4 languageName: node linkType: hard @@ -3211,12 +5860,26 @@ __metadata: languageName: node linkType: hard -"react@npm:^18": +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10c0/2bdb6b93fbb1820b024b496042cce405c57e2f85e777c9aabd55f9b26d145408f9f74f5934676ffdc46f3dcff656d78413a6e43968e7b3f92eea35b3052e9053 + languageName: node + linkType: hard + +"react-is@npm:^18.0.0": version: 18.2.0 - resolution: "react@npm:18.2.0" + resolution: "react-is@npm:18.2.0" + checksum: 10c0/6eb5e4b28028c23e2bfcf73371e72cd4162e4ac7ab445ddae2afe24e347a37d6dc22fae6e1748632cd43c6d4f9b8f86dcf26bf9275e1874f436d129952528ae0 + languageName: node + linkType: hard + +"react@npm:^18.3.0": + version: 18.3.0 + resolution: "react@npm:18.3.0" dependencies: loose-envify: "npm:^1.1.0" - checksum: 10c0/b562d9b569b0cb315e44b48099f7712283d93df36b19a39a67c254c6686479d3980b7f013dc931f4a5a3ae7645eae6386b4aa5eea933baa54ecd0f9acb0902b8 + checksum: 10c0/ad87bbfdb0c5466148c657da18b0d5458e835389fc591d59840f0e6ec797a004073a01c8cdbff1767a8774c7219054a56f74dacd67bdbb849f1314e427999268 languageName: node linkType: hard @@ -3238,6 +5901,16 @@ __metadata: languageName: node linkType: hard +"redent@npm:^3.0.0": + version: 3.0.0 + resolution: "redent@npm:3.0.0" + dependencies: + indent-string: "npm:^4.0.0" + strip-indent: "npm:^3.0.0" + checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.4": version: 1.0.5 resolution: "reflect.getprototypeof@npm:1.0.5" @@ -3260,7 +5933,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.0, regexp.prototype.flags@npm:^1.5.2": +"regexp.prototype.flags@npm:^1.5.0, regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.2": version: 1.5.2 resolution: "regexp.prototype.flags@npm:1.5.2" dependencies: @@ -3272,6 +5945,29 @@ __metadata: languageName: node linkType: hard +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + +"resolve-cwd@npm:^3.0.0": + version: 3.0.0 + resolution: "resolve-cwd@npm:3.0.0" + dependencies: + resolve-from: "npm:^5.0.0" + checksum: 10c0/e608a3ebd15356264653c32d7ecbc8fd702f94c6703ea4ac2fb81d9c359180cba0ae2e6b71faa446631ed6145454d5a56b227efc33a2d40638ac13f8beb20ee4 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3279,6 +5975,13 @@ __metadata: languageName: node linkType: hard +"resolve-from@npm:^5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -3286,7 +5989,14 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.7, resolve@npm:^1.22.2, resolve@npm:^1.22.4": +"resolve.exports@npm:^2.0.0": + version: 2.0.2 + resolution: "resolve.exports@npm:2.0.2" + checksum: 10c0/cc4cffdc25447cf34730f388dca5021156ba9302a3bad3d7f168e790dc74b2827dff603f1bc6ad3d299bac269828dca96dd77e036dc9fba6a2a1807c47ab5c98 + languageName: node + linkType: hard + +"resolve@npm:^1.1.7, resolve@npm:^1.20.0, resolve@npm:^1.22.2, resolve@npm:^1.22.4": version: 1.22.8 resolution: "resolve@npm:1.22.8" dependencies: @@ -3312,7 +6022,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": +"resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.2#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.8 resolution: "resolve@patch:resolve@npm%3A1.22.8#optional!builtin::version=1.22.8&hash=c3c19d" dependencies: @@ -3402,16 +6112,25 @@ __metadata: languageName: node linkType: hard -"scheduler@npm:^0.23.0": - version: 0.23.0 - resolution: "scheduler@npm:0.23.0" +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + +"scheduler@npm:^0.23.1": + version: 0.23.1 + resolution: "scheduler@npm:0.23.1" dependencies: loose-envify: "npm:^1.1.0" - checksum: 10c0/b777f7ca0115e6d93e126ac490dbd82642d14983b3079f58f35519d992fa46260be7d6e6cede433a92db70306310c6f5f06e144f0e40c484199e09c1f7be53dd + checksum: 10c0/cfda827a445fb57192e05275040eccc7c5e2749b98f15559520c7f6539d89d75633bb8b6c1cedf56ca0546630b72d0958bf00b63e2b8f9296e87d0d9d2d50e35 languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -3420,7 +6139,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.4": +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4": version: 7.6.0 resolution: "semver@npm:7.6.0" dependencies: @@ -3485,6 +6204,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + "signal-exit@npm:^4.0.1": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -3492,6 +6218,13 @@ __metadata: languageName: node linkType: hard +"sisteransi@npm:^1.0.5": + version: 1.0.5 + resolution: "sisteransi@npm:1.0.5" + checksum: 10c0/230ac975cca485b7f6fe2b96a711aa62a6a26ead3e6fb8ba17c5a00d61b8bed0d7adc21f5626b70d7c33c62ff4e63933017a6462942c719d1980bb0b1207ad46 + languageName: node + linkType: hard + "slash@npm:^3.0.0": version: 3.0.0 resolution: "slash@npm:3.0.0" @@ -3534,6 +6267,23 @@ __metadata: languageName: node linkType: hard +"source-map-support@npm:0.5.13": + version: 0.5.13 + resolution: "source-map-support@npm:0.5.13" + dependencies: + buffer-from: "npm:^1.0.0" + source-map: "npm:^0.6.0" + checksum: 10c0/137539f8c453fa0f496ea42049ab5da4569f96781f6ac8e5bfda26937be9494f4e8891f523c5f98f0e85f71b35d74127a00c46f83f6a4f54672b58d53202565e + languageName: node + linkType: hard + +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.1": + version: 0.6.1 + resolution: "source-map@npm:0.6.1" + checksum: 10c0/ab55398007c5e5532957cb0beee2368529618ac0ab372d789806f5718123cc4367d57de3904b4e6a4170eb5a0b0f41373066d02ca0735a0c4d75c7d328d3e011 + languageName: node + linkType: hard + "sprintf-js@npm:^1.1.3": version: 1.1.3 resolution: "sprintf-js@npm:1.1.3" @@ -3541,6 +6291,13 @@ __metadata: languageName: node linkType: hard +"sprintf-js@npm:~1.0.2": + version: 1.0.3 + resolution: "sprintf-js@npm:1.0.3" + checksum: 10c0/ecadcfe4c771890140da5023d43e190b7566d9cf8b2d238600f31bec0fc653f328da4450eb04bd59a431771a8e9cc0e118f0aa3974b683a4981b4e07abc2a5bb + languageName: node + linkType: hard + "ssri@npm:^10.0.0": version: 10.0.5 resolution: "ssri@npm:10.0.5" @@ -3550,6 +6307,24 @@ __metadata: languageName: node linkType: hard +"stack-utils@npm:^2.0.3": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a + languageName: node + linkType: hard + +"stop-iteration-iterator@npm:^1.0.0": + version: 1.0.0 + resolution: "stop-iteration-iterator@npm:1.0.0" + dependencies: + internal-slot: "npm:^1.0.4" + checksum: 10c0/c4158d6188aac510d9e92925b58709207bd94699e9c31186a040c80932a687f84a51356b5895e6dc72710aad83addb9411c22171832c9ae0e6e11b7d61b0dfb9 + languageName: node + linkType: hard + "streamsearch@npm:^1.1.0": version: 1.1.0 resolution: "streamsearch@npm:1.1.0" @@ -3557,7 +6332,17 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": +"string-length@npm:^4.0.1": + version: 4.0.2 + resolution: "string-length@npm:4.0.2" + dependencies: + char-regex: "npm:^1.0.2" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/1cd77409c3d7db7bc59406f6bcc9ef0783671dcbabb23597a1177c166906ef2ee7c8290f78cae73a8aec858768f189d2cb417797df5e15ec4eb5e16b3346340c + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -3654,6 +6439,29 @@ __metadata: languageName: node linkType: hard +"strip-bom@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-bom@npm:4.0.0" + checksum: 10c0/26abad1172d6bc48985ab9a5f96c21e440f6e7e476686de49be813b5a59b3566dccb5c525b831ec54fe348283b47f3ffb8e080bc3f965fde12e84df23f6bb7ef + languageName: node + linkType: hard + +"strip-final-newline@npm:^2.0.0": + version: 2.0.0 + resolution: "strip-final-newline@npm:2.0.0" + checksum: 10c0/bddf8ccd47acd85c0e09ad7375409d81653f645fda13227a9d459642277c253d877b68f2e5e4d819fe75733b0e626bac7e954c04f3236f6d196f79c94fa4a96f + languageName: node + linkType: hard + +"strip-indent@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-indent@npm:3.0.0" + dependencies: + min-indent: "npm:^1.0.0" + checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679 + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -3695,6 +6503,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 + languageName: node + linkType: hard + "supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" @@ -3704,6 +6521,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: "npm:^4.0.0" + checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89 + languageName: node + linkType: hard + "supports-preserve-symlinks-flag@npm:^1.0.0": version: 1.0.0 resolution: "supports-preserve-symlinks-flag@npm:1.0.0" @@ -3711,6 +6537,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "tailwindcss@npm:^3.3.0": version: 3.4.1 resolution: "tailwindcss@npm:3.4.1" @@ -3765,6 +6598,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^6.0.0": + version: 6.0.0 + resolution: "test-exclude@npm:6.0.0" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^7.1.4" + minimatch: "npm:^3.0.4" + checksum: 10c0/019d33d81adff3f9f1bfcff18125fb2d3c65564f437d9be539270ee74b994986abb8260c7c2ce90e8f30162178b09dbbce33c6389273afac4f36069c48521f57 + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -3790,6 +6634,20 @@ __metadata: languageName: node linkType: hard +"tmpl@npm:1.0.5": + version: 1.0.5 + resolution: "tmpl@npm:1.0.5" + checksum: 10c0/f935537799c2d1922cb5d6d3805f594388f75338fe7a4a9dac41504dd539704ca4db45b883b52e7b0aa5b2fd5ddadb1452bf95cd23a69da2f793a843f9451cc9 + languageName: node + linkType: hard + +"to-fast-properties@npm:^2.0.0": + version: 2.0.0 + resolution: "to-fast-properties@npm:2.0.0" + checksum: 10c0/b214d21dbfb4bce3452b6244b336806ffea9c05297148d32ebb428d5c43ce7545bdfc65a1ceb58c9ef4376a65c0cb2854d645f33961658b3e3b4f84910ddcdd7 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -3799,6 +6657,27 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.2": + version: 4.1.3 + resolution: "tough-cookie@npm:4.1.3" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/4fc0433a0cba370d57c4b240f30440c848906dee3180bb6e85033143c2726d322e7e4614abb51d42d111ebec119c4876ed8d7247d4113563033eebbc1739c831 + languageName: node + linkType: hard + +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" + dependencies: + punycode: "npm:^2.1.1" + checksum: 10c0/cdc47cad3a9d0b6cb293e39ccb1066695ae6fdd39b9e4f351b010835a1f8b4f3a6dc3a55e896b421371187f22b48d7dac1b693de4f6551bdef7b6ab6735dfe3b + languageName: node + linkType: hard + "ts-api-utils@npm:^1.0.1": version: 1.2.1 resolution: "ts-api-utils@npm:1.2.1" @@ -3815,6 +6694,44 @@ __metadata: languageName: node linkType: hard +"ts-node@npm:^10.9.2": + version: 10.9.2 + resolution: "ts-node@npm:10.9.2" + dependencies: + "@cspotcode/source-map-support": "npm:^0.8.0" + "@tsconfig/node10": "npm:^1.0.7" + "@tsconfig/node12": "npm:^1.0.7" + "@tsconfig/node14": "npm:^1.0.0" + "@tsconfig/node16": "npm:^1.0.2" + acorn: "npm:^8.4.1" + acorn-walk: "npm:^8.1.1" + arg: "npm:^4.1.0" + create-require: "npm:^1.1.0" + diff: "npm:^4.0.1" + make-error: "npm:^1.1.1" + v8-compile-cache-lib: "npm:^3.0.1" + yn: "npm:3.1.1" + peerDependencies: + "@swc/core": ">=1.2.50" + "@swc/wasm": ">=1.2.50" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-esm: dist/bin-esm.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: 10c0/5f29938489f96982a25ba650b64218e83a3357d76f7bede80195c65ab44ad279c8357264639b7abdd5d7e75fc269a83daa0e9c62fd8637a3def67254ecc9ddc2 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" @@ -3843,6 +6760,13 @@ __metadata: languageName: node linkType: hard +"type-detect@npm:4.0.8": + version: 4.0.8 + resolution: "type-detect@npm:4.0.8" + checksum: 10c0/8fb9a51d3f365a7de84ab7f73b653534b61b622aa6800aecdb0f1095a4a646d3f5eb295322127b6573db7982afcd40ab492d038cf825a42093a58b1e1353e0bd + languageName: node + linkType: hard + "type-fest@npm:^0.20.2": version: 0.20.2 resolution: "type-fest@npm:0.20.2" @@ -3850,6 +6774,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.21.3": + version: 0.21.3 + resolution: "type-fest@npm:0.21.3" + checksum: 10c0/902bd57bfa30d51d4779b641c2bc403cdf1371fb9c91d3c058b0133694fcfdb817aef07a47f40faf79039eecbaa39ee9d3c532deff244f3a19ce68cea71a61e8 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.1": version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2" @@ -3959,6 +6890,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.0.13": version: 1.0.13 resolution: "update-browserslist-db@npm:1.0.13" @@ -3982,6 +6920,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.2": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -3989,6 +6937,75 @@ __metadata: languageName: node linkType: hard +"v8-compile-cache-lib@npm:^3.0.1": + version: 3.0.1 + resolution: "v8-compile-cache-lib@npm:3.0.1" + checksum: 10c0/bdc36fb8095d3b41df197f5fb6f11e3a26adf4059df3213e3baa93810d8f0cc76f9a74aaefc18b73e91fe7e19154ed6f134eda6fded2e0f1c8d2272ed2d2d391 + languageName: node + linkType: hard + +"v8-to-istanbul@npm:^9.0.1": + version: 9.2.0 + resolution: "v8-to-istanbul@npm:9.2.0" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" + "@types/istanbul-lib-coverage": "npm:^2.0.1" + convert-source-map: "npm:^2.0.0" + checksum: 10c0/e691ba4dd0dea4a884e52c37dbda30cce6f9eeafe9b26721e449429c6bb0f4b6d1e33fabe7711d0f67f7a34c3bfd56c873f7375bba0b1534e6a2843ce99550e5 + languageName: node + linkType: hard + +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" + dependencies: + xml-name-validator: "npm:^4.0.0" + checksum: 10c0/02cc66d6efc590bd630086cd88252444120f5feec5c4043932b0d0f74f8b060512f79dc77eb093a7ad04b4f02f39da79ce4af47ceb600f2bf9eacdc83204b1a8 + languageName: node + linkType: hard + +"walker@npm:^1.0.8": + version: 1.0.8 + resolution: "walker@npm:1.0.8" + dependencies: + makeerror: "npm:1.0.12" + checksum: 10c0/a17e037bccd3ca8a25a80cb850903facdfed0de4864bd8728f1782370715d679fa72e0a0f5da7c1c1379365159901e5935f35be531229da53bbfc0efdabdb48e + languageName: node + linkType: hard + +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/91b90a49f312dc751496fd23a7e68981e62f33afe938b97281ad766235c4872fc4e66319f925c5e9001502b3040dd25a33b02a9c693b73a4cbbfdc4ad10c3e3e + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10c0/323895a1cda29a5fb0b9ca82831d2c316309fede0365047c4c323073e3239067a304a09a1f4b123b9532641ab604203f33a1403b5ca6a62ef405bcd7a204080f + languageName: node + linkType: hard + +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" + dependencies: + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/f7ec264976d7c725e0696fcaf9ebe056e14422eacbf92fdbb4462034609cba7d0c85ffa1aab05e9309d42969bcf04632ba5ed3f3882c516d7b093053315bf4c1 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" @@ -4034,6 +7051,19 @@ __metadata: languageName: node linkType: hard +"which-typed-array@npm:^1.1.13": + version: 1.1.15 + resolution: "which-typed-array@npm:1.1.15" + dependencies: + available-typed-arrays: "npm:^1.0.7" + call-bind: "npm:^1.0.7" + for-each: "npm:^0.3.3" + gopd: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/4465d5348c044032032251be54d8988270e69c6b7154f8fcb2a47ff706fe36f7624b3a24246b8d9089435a8f4ec48c1c1025c5d6b499456b9e5eff4f48212983 + languageName: node + linkType: hard + "which-typed-array@npm:^1.1.14, which-typed-array@npm:^1.1.9": version: 1.1.14 resolution: "which-typed-array@npm:1.1.14" @@ -4069,7 +7099,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -4098,6 +7128,59 @@ __metadata: languageName: node linkType: hard +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 10c0/a2c282c95ef5d8e1c27b335ae897b5eca00e85590d92a3fd69a437919b7b93ff36a69ea04145da55829d2164e724bc62202cdb5f4b208b425aba0807889375c7 + languageName: node + linkType: hard + +"ws@npm:^8.11.0": + version: 8.16.0 + resolution: "ws@npm:8.16.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/a7783bb421c648b1e622b423409cb2a58ac5839521d2f689e84bc9dc41d59379c692dd405b15a997ea1d4c0c2e5314ad707332d0c558f15232d2bc07c0b4618a + languageName: node + linkType: hard + +"xml-name-validator@npm:^4.0.0": + version: 4.0.0 + resolution: "xml-name-validator@npm:4.0.0" + checksum: 10c0/c1bfa219d64e56fee265b2bd31b2fcecefc063ee802da1e73bad1f21d7afd89b943c9e2c97af2942f60b1ad46f915a4c81e00039c7d398b53cf410e29d3c30bd + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -4114,6 +7197,35 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.3.1": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard + +"yn@npm:3.1.1": + version: 3.1.1 + resolution: "yn@npm:3.1.1" + checksum: 10c0/0732468dd7622ed8a274f640f191f3eaf1f39d5349a1b72836df484998d7d9807fbea094e2f5486d6b0cd2414aad5775972df0e68f8604db89a239f0f4bf7443 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" diff --git a/taskfiles/backend.yml b/taskfiles/backend.yml index 6e622e8..3763e96 100644 --- a/taskfiles/backend.yml +++ b/taskfiles/backend.yml @@ -2,14 +2,85 @@ version: "3" tasks: dev: + dir: backend + cmds: + - task back:db-up -s + - defer: task back:db-down -s + - task back:run -s + run: dir: backend/cmd/api cmds: - go run . test: dir: backend cmds: - - go test -v ./backend/** + - task back:db-test-up -s + - defer: task back:db-test-down -s + - go test -v ./internal/** tidy: dir: backend cmds: - go mod tidy + + # Docker commands + + db-up: + dir: backend/remote/development + cmds: + # First check if docker engine is running. + # Retrieved from: https://stackoverflow.com/a/55283209/20087581 + - | + if ! docker info > /dev/null 2>&1; then + echo "This script uses docker, and it isn't running - please start docker and try again." + exit 1 + fi + + # Then run compose + - docker compose --env-file ../../.env up -d + + # db-stop stops running containers, does not delete them. + db-stop: + dir: backend/remote/development + cmds: + - docker compose stop + db-down: + dir: backend/remote/development + cmds: + - docker compose down + + # Rebuild the container(s) due to adjusted settings. + db-build: + dir: backend/remote/development + cmds: + - docker compose build + + # db-down-clean deletes volumes and their data! See: + # https://docs.docker.com/compose/gettingstarted/#step-8-experiment-with-some-other-commands + db-down-clean: + dir: backend/remote/development + cmds: + - docker compose down --volumes + # https://forums.docker.com/t/how-to-delete-cache/5753/2 + # The darkspace-backend-database name comes from the + # compose.yaml file. It starts with the name, + # darkspace-backend, then the name of the service, + # in this case it is "database". + - docker rmi darkspace-dev-database + + # db-test-up does a docker compose on the test database. + db-test-up: + dir: backend/remote/test + cmds: + - | + if ! docker info > /dev/null 2>&1; then + echo "This script uses docker, and it isn't running - please start docker and try again." + exit 1 + fi + - docker compose -f compose.yaml --env-file ../../.env up -d + + # db-test-down does a db-down-clean operation on the test compose file. + db-test-down: + dir: backend/remote/test + cmds: + - docker compose down --volumes --remove-orphans + - docker rmi darkspace-test-database \ No newline at end of file diff --git a/taskfiles/frontend.yml b/taskfiles/frontend.yml index e5a5740..452dd92 100644 --- a/taskfiles/frontend.yml +++ b/taskfiles/frontend.yml @@ -12,7 +12,7 @@ tasks: test: dir: frontend cmds: - - echo "nothing yet" + - yarn test install: dir: frontend cmds: