diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..562de9bf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +# App configuration +APP_ENV=development +PORT=6969 + +# Database configuration +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=postgres +DB_HOST=localhost +DB_PORT=5432 +DB_SSLMODE=disable + +# JWT configuration +JWT_SECRET=your-jwt-secret + +# AWS configuration +AWS_REGION=us-west-2 +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key +AWS_S3_BUCKET=your-bucket-name \ No newline at end of file diff --git a/backend/common/env_name.go b/backend/common/env_name.go index a036ae4e..841cfe54 100644 --- a/backend/common/env_name.go +++ b/backend/common/env_name.go @@ -2,3 +2,4 @@ package common const PRODUCTION_ENV = "production" const DEVELOPMENT_ENV = "development" +const STAGING_ENV = "staging" diff --git a/backend/db/db.go b/backend/db/db.go index b4a3b78a..5470edf8 100644 --- a/backend/db/db.go +++ b/backend/db/db.go @@ -29,4 +29,4 @@ func (q *Queries) WithTx(tx pgx.Tx) *Queries { return &Queries{ db: tx, } -} +} \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 912fe720..59edfae8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,6 +15,24 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.32.6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect + github.com/aws/aws-sdk-go-v2/config v1.28.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.47 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect + github.com/aws/smithy-go v1.22.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -23,6 +41,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index dc39b8ee..d047395f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,39 @@ +github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= +github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc= +github.com/aws/aws-sdk-go-v2/config v1.28.6 h1:D89IKtGrs/I3QXOLNTH93NJYtDhm8SYa9Q5CsPShmyo= +github.com/aws/aws-sdk-go-v2/config v1.28.6/go.mod h1:GDzxJ5wyyFSCoLkS+UhGB0dArhb9mI+Co4dHtoTxbko= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47 h1:48bA+3/fCdi2yAwVt+3COvmatZ6jUDNkDTIsqDiMUdw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.47/go.mod h1:+KdckOejLW3Ks3b0E3b5rHsr2f9yuORBum0WPnE5o5w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 h1:AmoU1pziydclFT/xRV+xXE/Vb8fttJCLRPv8oAkprc0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21/go.mod h1:AjUdLYe4Tgs6kpH4Bv7uMZo7pottoyHMn4eTcIcneaY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 h1:s/fF4+yDQDoElYhfIVvSNyeCydfbuTKzhxSXDXCPasU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25/go.mod h1:IgPfDv5jqFIzQSNbUEMoitNooSMXjRSDkhXv8jiROvU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25 h1:ZntTCl5EsYnhN/IygQEUugpdwbhdkom9uHcbCftiGgA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.25/go.mod h1:DBdPrgeocww+CSl1C8cEV8PN1mHMBhuCDLpXezyvWkE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25 h1:r67ps7oHCYnflpgDy2LZU0MAQtQbYIOqNNnqGO6xQkE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.25/go.mod h1:GrGY+Q4fIokYLtjCVB/aFfCVL6hhGUFl8inD18fDalE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6 h1:HCpPsWqmYQieU7SS6E9HXfdAMSud0pteVXieJmcpIRI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.6/go.mod h1:ngUiVRCco++u+soRRVBIvBZxSMMvOVMXA4PJ36JLfSw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6 h1:50+XsN70RS7dwJ2CkVNXzj7U2L1HKP8nqTd3XWEXBN4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.6/go.mod h1:WqgLmwY7so32kG01zD8CPTJWVWM+TzJoOVHwTg4aPug= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6 h1:BbGDtTi0T1DYlmjBiCr/le3wzhA37O8QTC5/Ab8+EXk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.6/go.mod h1:hLMJt7Q8ePgViKupeymbqI0la+t9/iYFBjxQCFwuAwI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0 h1:nyuzXooUNJexRT0Oy0UQY6AhOzxPxhtt4DcBIHyCnmw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.71.0/go.mod h1:sT/iQz8JK3u/5gZkT+Hmr7GzVZehUMkRZpOaAwYXeGY= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7 h1:rLnYAfXQ3YAccocshIH5mzNNwZBkBo+bP6EhIxak6Hw= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.7/go.mod h1:ZHtuQJ6t9A/+YDuxOLnbryAmITtr8UysSny3qcyvJTc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 h1:JnhTZR3PiYDNKlXy50/pNeix9aGMo6lLpXwJ1mw8MD4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6/go.mod h1:URronUEGfXZN1VpdktPSD1EkAL9mfrV+2F4sjH38qOY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HSQqJukaLuuW0TpDA= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,6 +64,9 @@ github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 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.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -81,6 +120,7 @@ golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/middleware/req_validator.go b/backend/internal/middleware/req_validator.go index d1444da4..198a9818 100644 --- a/backend/internal/middleware/req_validator.go +++ b/backend/internal/middleware/req_validator.go @@ -3,7 +3,9 @@ package middleware import ( "fmt" "net/http" + "os" "reflect" + "strings" "KonferCA/SPUR/db" "github.com/go-playground/validator/v10" @@ -33,6 +35,7 @@ func NewRequestBodyValidator() *RequestBodyValidator { v := validator.New() v.RegisterValidation("valid_user_role", validateUserRole) v.RegisterValidation("non_admin_role", validateNonAdminRole) + v.RegisterValidation("s3_url", validateS3URL) return &RequestBodyValidator{validator: v} } @@ -105,3 +108,15 @@ func validateNonAdminRole(fl validator.FieldLevel) bool { return false } + +// validateS3URL ensures that file URLs point to our S3 bucket +func validateS3URL(fl validator.FieldLevel) bool { + url := fl.Field().String() + bucket := os.Getenv("AWS_S3_BUCKET") + if bucket == "" { + log.Warn().Msg("AWS_S3_BUCKET environment variable not set") + return false + } + expectedPrefix := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/", bucket) + return strings.HasPrefix(url, expectedPrefix) +} diff --git a/backend/internal/middleware/upload.go b/backend/internal/middleware/upload.go new file mode 100644 index 00000000..ee79a747 --- /dev/null +++ b/backend/internal/middleware/upload.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +func UploadMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Check if the request is multipart/form-data + if c.Request().Header.Get("Content-Type") != "" && c.Request().Method == "POST" { + err := c.Request().ParseMultipartForm(10 << 20) // 10 MB + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse multipart form") + } + } + return next(c) + } + } +} \ No newline at end of file diff --git a/backend/internal/server/index.go b/backend/internal/server/index.go index 3ee8040d..c0603a01 100644 --- a/backend/internal/server/index.go +++ b/backend/internal/server/index.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "net/http" "os" "reflect" "time" @@ -13,6 +14,7 @@ import ( "KonferCA/SPUR/common" "KonferCA/SPUR/db" "KonferCA/SPUR/internal/middleware" + "KonferCA/SPUR/storage" ) type Server struct { @@ -22,11 +24,18 @@ type Server struct { apiV1 *echo.Group authLimiter *middleware.RateLimiter apiLimiter *middleware.RateLimiter + Storage *storage.Storage } // Create a new Server instance and registers all routes and middlewares. // Initialize database pool connection. func New(testing bool) (*Server, error) { + var dbPool *pgxpool.Pool + var queries *db.Queries + var storageClient *storage.Storage + var err error + + // format connection string connStr := fmt.Sprintf( "host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", os.Getenv("DB_HOST"), @@ -36,16 +45,23 @@ func New(testing bool) (*Server, error) { os.Getenv("DB_NAME"), os.Getenv("DB_SSLMODE"), ) - pool, err := db.NewPool(connStr) + + // initialize database connection using pool.go + dbPool, err = db.NewPool(connStr) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to connect to database: %v", err) + } + queries = db.New(dbPool) + + if !testing { + // Initialize storage only for non-test environment + storageClient, err = storage.NewStorage() + if err != nil { + return nil, fmt.Errorf("failed to initialize storage: %v", err) + } } - - // Initialize queries - queries := db.New(pool) e := echo.New() - e.Debug = true // create rate limiters @@ -67,38 +83,16 @@ func New(testing bool) (*Server, error) { ) } - // setup error handler and middlewares - e.HTTPErrorHandler = globalErrorHandler - - if os.Getenv("APP_ENV") == common.DEVELOPMENT_ENV { - // use default cors config, allow everything in development - e.Use(echoMiddleware.CORS()) - } else if os.Getenv("APP_ENV") == common.PRODUCTION_ENV { - e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ - AllowOrigins: []string{"https://spur.konfer.ca"}, - })) - } else { - e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ - AllowOrigins: []string{"https://nk-preview.konfer.ca"}, - })) - } - - e.Use(middleware.Logger()) - e.Use(echoMiddleware.Recover()) - e.Use(apiLimiter.RateLimit()) // global rate limit - - customValidator := middleware.NewRequestBodyValidator() - e.Validator = customValidator - server := &Server{ - DBPool: pool, + DBPool: dbPool, queries: queries, echoInstance: e, authLimiter: authLimiter, apiLimiter: apiLimiter, + Storage: storageClient, } - // setup api routes + // setup api routes first server.setupV1Routes() server.setupAuthRoutes() server.setupCompanyRoutes() @@ -112,8 +106,49 @@ func New(testing bool) (*Server, error) { server.setupFundingTransactionRoutes() server.setupMeetingRoutes() server.setupHealthRoutes() + server.setupStorageRoutes() + + // setup error handler and middlewares after routes + e.HTTPErrorHandler = globalErrorHandler + + // setup cors based on environment + if os.Getenv("APP_ENV") == common.PRODUCTION_ENV { + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ + AllowOrigins: []string{"https://spur.konfer.ca"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderContentType, + echo.HeaderAccept, + echo.HeaderContentLength, + "X-Request-ID", + }, + })) + } else if os.Getenv("APP_ENV") == common.STAGING_ENV { + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ + AllowOrigins: []string{"https://nk-preview.konfer.ca"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderContentType, + echo.HeaderAccept, + echo.HeaderContentLength, + "X-Request-ID", + }, + })) + } else { + // use default cors middleware for development + e.Use(echoMiddleware.CORS()) + } + + e.Use(middleware.Logger()) + e.Use(echoMiddleware.Recover()) + e.Use(apiLimiter.RateLimit()) // global rate limit + + customValidator := middleware.NewRequestBodyValidator() + e.Validator = customValidator - // setup static routes + // setup static routes last server.setupStaticRoutes() return server, nil @@ -122,8 +157,9 @@ func New(testing bool) (*Server, error) { func (s *Server) setupV1Routes() { s.apiV1 = s.echoInstance.Group("/api/v1") + fmt.Println("Registered routes:") for _, route := range s.echoInstance.Routes() { - s.echoInstance.Logger.Printf("Route: %s %s", route.Method, route.Path) + fmt.Printf("Route: %s %s\n", route.Method, route.Path) } } diff --git a/backend/internal/server/projects.go b/backend/internal/server/projects.go index a5ec8178..adf3342f 100644 --- a/backend/internal/server/projects.go +++ b/backend/internal/server/projects.go @@ -2,8 +2,12 @@ package server import ( "context" - "fmt" "net/http" + "io" + "fmt" + "time" + "path/filepath" + "strings" "KonferCA/SPUR/db" mw "KonferCA/SPUR/internal/middleware" @@ -24,14 +28,15 @@ func (s *Server) handleCreateProject(c echo.Context) error { queries := db.New(s.DBPool) - _, err = queries.GetCompanyByID(context.Background(), companyID) + // Start a transaction + tx, err := s.DBPool.Begin(context.Background()) if err != nil { - return handleDBError(err, "verify", "company") + return handleDBError(err, "begin", "transaction") } + defer tx.Rollback(context.Background()) - fmt.Printf("Creating project with params: company_id=%v, title=%s, status=%s\n", - companyID, req.Title, req.Status) - + // Create project + qtx := queries.WithTx(tx) params := db.CreateProjectParams{ CompanyID: companyID, Title: req.Title, @@ -39,13 +44,41 @@ func (s *Server) handleCreateProject(c echo.Context) error { Status: req.Status, } - project, err := queries.CreateProject(context.Background(), params) + project, err := qtx.CreateProject(context.Background(), params) if err != nil { - fmt.Printf("Error creating project: %v\n", err) return handleDBError(err, "create", "project") } - fmt.Printf("Created project: %+v\n", project) + // Create files + for _, file := range req.Files { + fileParams := db.CreateProjectFileParams{ + ProjectID: project.ID, + FileType: file.FileType, + FileUrl: file.FileURL, + } + _, err := qtx.CreateProjectFile(context.Background(), fileParams) + if err != nil { + return handleDBError(err, "create", "project file") + } + } + + // Create links + for _, link := range req.Links { + linkParams := db.CreateProjectLinkParams{ + ProjectID: project.ID, + LinkType: link.LinkType, + Url: link.URL, + } + _, err := qtx.CreateProjectLink(context.Background(), linkParams) + if err != nil { + return handleDBError(err, "create", "project link") + } + } + + // Commit transaction + if err := tx.Commit(context.Background()); err != nil { + return handleDBError(err, "commit", "transaction") + } return c.JSON(http.StatusCreated, project) } @@ -114,30 +147,61 @@ func (s *Server) handleCreateProjectFile(c echo.Context) error { return err } - var req *CreateProjectFileRequest - req, ok := c.Get(mw.REQUEST_BODY_KEY).(*CreateProjectFileRequest) - if !ok { - return echo.NewHTTPError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) + // Get the file from form data + file, err := c.FormFile("file") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "file is required") } - queries := db.New(s.DBPool) - _, err = queries.GetProject(context.Background(), projectID) + fileType := c.FormValue("file_type") + if fileType == "" { + return echo.NewHTTPError(http.StatusBadRequest, "file_type is required") + } + + // Upload file to storage + src, err := file.Open() if err != nil { - return handleDBError(err, "verify", "project") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file") } + defer src.Close() + // Read file contents + fileBytes, err := io.ReadAll(src) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to read file contents") + } + + // Generate unique file key + fileExt := filepath.Ext(file.Filename) + fileKey := fmt.Sprintf("uploads/%d%s", time.Now().UnixNano(), fileExt) + + fileURL, err := s.Storage.UploadFile(c.Request().Context(), fileKey, fileBytes) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to upload file") + } + + // Create database record + queries := db.New(s.DBPool) params := db.CreateProjectFileParams{ ProjectID: projectID, - FileType: req.FileType, - FileUrl: req.FileURL, + FileType: fileType, + FileUrl: fileURL, } - file, err := queries.CreateProjectFile(context.Background(), params) + projectFile, err := queries.CreateProjectFile(context.Background(), params) if err != nil { + // Try to cleanup the uploaded file if database record creation fails + if fileURL != "" { + // Extract key from URL + parts := strings.Split(fileURL, ".amazonaws.com/") + if len(parts) == 2 { + _ = s.Storage.DeleteFile(c.Request().Context(), parts[1]) + } + } return handleDBError(err, "create", "project file") } - return c.JSON(http.StatusCreated, file) + return c.JSON(http.StatusCreated, projectFile) } func (s *Server) handleListProjectFiles(c echo.Context) error { diff --git a/backend/internal/server/storage.go b/backend/internal/server/storage.go new file mode 100644 index 00000000..2416105f --- /dev/null +++ b/backend/internal/server/storage.go @@ -0,0 +1,102 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go" + "github.com/labstack/echo/v4" +) + +type UploadResponse struct { + FileURL string `json:"file_url"` +} + +func (s *Server) setupStorageRoutes() { + fmt.Println("Setting up storage routes...") + s.apiV1.POST("/storage/upload", s.handleFileUpload) + fmt.Println("Storage routes set up") +} + +func (s *Server) handleFileUpload(c echo.Context) error { + fmt.Println("Handling file upload...") + fmt.Printf("Content-Type: %s\n", c.Request().Header.Get("Content-Type")) + + // Parse multipart form with 10MB limit + err := c.Request().ParseMultipartForm(10 << 20) + if err != nil { + fmt.Printf("Error parsing multipart form: %v\n", err) + return echo.NewHTTPError(http.StatusBadRequest, "Failed to parse multipart form") + } + + // Get file from request + file, err := c.FormFile("file") + if err != nil { + fmt.Printf("Error getting form file: %v\n", err) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid file upload") + } + + // Open the uploaded file + src, err := file.Open() + if err != nil { + fmt.Printf("Error opening file: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to read uploaded file") + } + defer src.Close() + + // Load AWS configuration + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion("us-east-1"), + config.WithClientLogMode(aws.LogRequestWithBody|aws.LogResponseWithBody), + ) + if err != nil { + fmt.Printf("Error loading AWS config: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load AWS config") + } + + // Create S3 client with custom endpoint + bucket := os.Getenv("AWS_S3_BUCKET") + s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + o.EndpointResolver = s3.EndpointResolverFromURL(fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com", bucket)) + + }) + + // Generate unique file key + fileExt := filepath.Ext(file.Filename) + fileKey := fmt.Sprintf("uploads/%d%s", time.Now().UnixNano(), fileExt) + + // Upload to S3 + _, err = s3Client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(fileKey), + Body: src, + ContentType: aws.String(file.Header.Get("Content-Type")), + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + fmt.Printf("Response error details: %+v\n", apiErr) + } + fmt.Printf("Error uploading to S3: %v\n", err) + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upload to S3") + } + + // Construct the file URL + fileURL := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/%s", + bucket, + fileKey) + + fmt.Printf("File uploaded successfully: %s\n", fileURL) + return c.JSON(http.StatusOK, UploadResponse{ + FileURL: fileURL, + }) +} \ No newline at end of file diff --git a/backend/internal/server/types.go b/backend/internal/server/types.go index a8bf50ae..81811c23 100644 --- a/backend/internal/server/types.go +++ b/backend/internal/server/types.go @@ -119,11 +119,36 @@ type UpdateCompanyAnswerRequest struct { AnswerText string `json:"answer_text" validate:"required"` } +type TeamMember struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Avatar string `json:"avatar,omitempty"` +} + +type ProjectFile struct { + FileType string `json:"file_type" validate:"required"` + FileURL string `json:"file_url" validate:"required,url,s3_url"` +} + +type ProjectLink struct { + LinkType string `json:"link_type" validate:"required"` + URL string `json:"url" validate:"required,url"` +} + type CreateProjectRequest struct { - CompanyID string `json:"company_id" validate:"required"` - Title string `json:"title" validate:"required"` - Description *string `json:"description"` - Status string `json:"status" validate:"required"` + CompanyID string `json:"company_id" validate:"required"` + Title string `json:"title" validate:"required"` + Description *string `json:"description"` + Status string `json:"status" validate:"required"` + FoundedDate string `json:"founded_date,omitempty"` + CompanyStage string `json:"company_stage,omitempty"` + InvestmentStage string `json:"investment_stage,omitempty"` + Inspiration string `json:"inspiration,omitempty"` + Vision string `json:"vision,omitempty"` + TeamMembers []TeamMember `json:"team_members,omitempty" validate:"dive"` + Files []ProjectFile `json:"files" validate:"dive"` + Links []ProjectLink `json:"links" validate:"dive"` } type UpdateProjectRequest struct { @@ -132,21 +157,11 @@ type UpdateProjectRequest struct { Status string `json:"status" validate:"required"` } -type CreateProjectFileRequest struct { - FileType string `json:"file_type" validate:"required"` - FileURL string `json:"file_url" validate:"required,url"` -} - type CreateProjectCommentRequest struct { UserID string `json:"user_id" validate:"required"` Comment string `json:"comment" validate:"required"` } -type CreateProjectLinkRequest struct { - LinkType string `json:"link_type" validate:"required"` - URL string `json:"url" validate:"required,url"` -} - type AddProjectTagRequest struct { TagID string `json:"tag_id" validate:"required,uuid"` } @@ -186,3 +201,13 @@ type UpdateMeetingRequest struct { Location *string `json:"location"` Notes *string `json:"notes"` } + +type CreateProjectFileRequest struct { + FileType string `json:"file_type" validate:"required"` + FileURL string `json:"file_url" validate:"required,url,s3_url"` +} + +type CreateProjectLinkRequest struct { + LinkType string `json:"link_type" validate:"required"` + URL string `json:"url" validate:"required,url"` +} diff --git a/backend/storage/storage.go b/backend/storage/storage.go new file mode 100644 index 00000000..60872c89 --- /dev/null +++ b/backend/storage/storage.go @@ -0,0 +1,87 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type Storage struct { + s3Client *s3.Client + bucket string +} + +// NewStorage creates a new Storage instance with S3 client +func NewStorage() (*Storage, error) { + bucket := os.Getenv("AWS_S3_BUCKET") + if bucket == "" { + return nil, fmt.Errorf("AWS_S3_BUCKET environment variable not set") + } + + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + return nil, fmt.Errorf("unable to load SDK config: %v", err) + } + + client := s3.NewFromConfig(cfg) + + return &Storage{ + s3Client: client, + bucket: bucket, + }, nil +} + +// ValidateFileURL checks if a file URL points to our S3 bucket +func (s *Storage) ValidateFileURL(url string) bool { + expectedPrefix := fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/", s.bucket) + return url != "" && len(url) > len(expectedPrefix) && url[:len(expectedPrefix)] == expectedPrefix +} + +// GetPresignedURL generates a presigned URL for uploading a file +func (s *Storage) GetPresignedURL(ctx context.Context, key string) (string, error) { + presignClient := s3.NewPresignClient(s.s3Client) + + putObjectInput := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + } + + presignedReq, err := presignClient.PresignPutObject(ctx, putObjectInput) + if err != nil { + return "", fmt.Errorf("couldn't get presigned URL: %v", err) + } + + return presignedReq.URL, nil +} + +// UploadFile uploads a file to S3 and returns its URL +func (s *Storage) UploadFile(ctx context.Context, key string, data []byte) (string, error) { + _, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + }) + if err != nil { + return "", fmt.Errorf("couldn't upload file: %v", err) + } + + return fmt.Sprintf("https://%s.s3.us-east-1.amazonaws.com/%s", s.bucket, key), nil +} + +// DeleteFile deletes a file from S3 +func (s *Storage) DeleteFile(ctx context.Context, key string) error { + _, err := s.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("couldn't delete file: %v", err) + } + + return nil +} \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..088334e0 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import type { User } from '@/types'; + +interface AuthContextType { + user: User | null; + companyId: string | null; + setUser: (user: User | null) => void; + setCompanyId: (id: string | null) => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [companyId, setCompanyId] = useState(null); + + // Load user from localStorage on mount + useEffect(() => { + const storedUser = localStorage.getItem('user'); + const storedCompanyId = localStorage.getItem('company_id'); + + if (storedUser) { + setUser(JSON.parse(storedUser)); + } + if (storedCompanyId) { + setCompanyId(storedCompanyId); + } + }, []); + + // Save user to localStorage when it changes + useEffect(() => { + if (user) { + localStorage.setItem('user', JSON.stringify(user)); + } else { + localStorage.removeItem('user'); + } + }, [user]); + + // Save companyId to localStorage when it changes + useEffect(() => { + if (companyId) { + localStorage.setItem('company_id', companyId); + } else { + localStorage.removeItem('company_id'); + } + }, [companyId]); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index e71edf0f..98252345 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, FormEvent } from 'react'; import { Button, TextInput, TextArea } from '@components'; import { register, RegisterError, saveRefreshToken } from '@services'; +import { useAuth } from '@/contexts/AuthContext'; type RegistrationStep = | 'login-register' @@ -38,6 +39,7 @@ const Register = () => { password: '', }); const [errors, setErrors] = useState({}); + const { setUser, setCompanyId } = useAuth(); const LINKEDIN_REGEX = /^(https?:\/\/)?([\w]+\.)?linkedin\.com\/(pub|in|profile)\/([-a-zA-Z0-9]+)\/?$/; @@ -87,21 +89,17 @@ const Register = () => { const handleInitialSubmit = async (e: FormEvent) => { e.preventDefault(); try { - // user information and access token should be stored in an auth provider const regResp = await register(formData.email, formData.password); console.log(regResp); - // save the refresh token in local storage and use it to request a new - // access token later when the user reopens the web app. - // IMPORTANT: for now localstorage works, but by MVP we need a more proper - // way to handle authentication using HTTP-only cookies and adding - // fingerprint information to tokens to prevet XSS. - // for more info: https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html#token-storage-on-client-side + setUser(regResp.user); saveRefreshToken(regResp.refreshToken); + + setCompanyId('mock-company-id'); + setCurrentStep('verify-email'); } catch (error) { if (error instanceof RegisterError) { - // TODO: handle error with some kind of notification console.log('do something here', error.statusCode, error.body); } else { // TODO: handle error with some kind of notification diff --git a/frontend/src/pages/SubmitProject.tsx b/frontend/src/pages/SubmitProject.tsx index b9f5ed9b..ec4c8e27 100644 --- a/frontend/src/pages/SubmitProject.tsx +++ b/frontend/src/pages/SubmitProject.tsx @@ -3,10 +3,100 @@ import { useState } from 'react'; import { UserDashboard, TextInput, Dropdown, Section, Button, FileUpload, AnchorLinks, TeamMembers, SocialLinks, TextArea, DateInput } from '@components'; import type { FormField, FormData } from '@/types'; import { projectFormSchema } from '@/config/forms'; +import { createProject, createCompany } from '@/services'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; const SubmitProjectPage = () => { const [currentStep, setCurrentStep] = useState<'A' | 'B'>('A'); const [formData, setFormData] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isCreatingCompany, setIsCreatingCompany] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + const { user, companyId, setCompanyId } = useAuth(); + + const handleCreateCompany = async () => { + if (!user) { + setError('Please log in to create a company.'); + return; + } + + try { + setIsCreatingCompany(true); + setError(null); + + // Use the company name from the form if available, otherwise use a default name + const companyName = formData.companyName || 'My Company'; + const company = await createCompany(user.id, companyName, formData.description); + + setCompanyId(company.id); + setError(null); + } catch (err) { + console.error('Failed to create company:', err); + setError('Failed to create company. Please try again.'); + } finally { + setIsCreatingCompany(false); + } + }; + + const fillWithSampleData = () => { + const sampleData = { + // Bookkeeping + companyName: 'TechVision AI Solutions', + foundedDate: '2023-06-15', + companyStage: 'seed', + investmentStage: 'seed', + + // Company Overview + description: 'TechVision AI Solutions is a cutting-edge artificial intelligence company focused on developing innovative computer vision solutions for retail and manufacturing industries. Our proprietary AI algorithms help businesses automate quality control, optimize inventory management, and enhance customer experiences.', + inspiration: 'After working in manufacturing for over a decade, we witnessed firsthand the inefficiencies and errors in manual quality control processes. This inspired us to develop an AI-powered solution that could perform inspections with greater accuracy and consistency, while significantly reducing costs and improving production speed.', + vision: 'Our vision is to become the global leader in AI-powered visual inspection and analytics. We aim to revolutionize how businesses handle quality control and inventory management by making advanced computer vision technology accessible and affordable for companies of all sizes. Within 5 years, we plan to expand our solutions across multiple industries and establish ourselves as the industry standard for automated visual inspection.', + + // Team Members + 'team-members': [ + { + id: '1', + name: 'Sarah Chen', + role: 'CEO & Co-founder', + avatar: '' + }, + { + id: '2', + name: 'Michael Rodriguez', + role: 'CTO & Co-founder', + avatar: '' + }, + { + id: '3', + name: 'Dr. Emily Thompson', + role: 'Head of AI Research', + avatar: '' + } + ], + + // Social Links + 'social-links': [ + { + id: '1', + type: 'website', + url: 'https://techvision-ai.com' + }, + { + id: '2', + type: 'linkedin', + url: 'https://linkedin.com/company/techvision-ai' + }, + { + id: '3', + type: 'twitter', + url: 'https://twitter.com/techvision_ai' + } + ] + }; + + setFormData(sampleData); + }; const handleNext = () => { setCurrentStep('B'); @@ -23,6 +113,37 @@ const SubmitProjectPage = () => { })); }; + const handleSubmit = async () => { + if (!companyId) { + setError('Company ID not found. Please create a company first.'); + return; + } + + try { + setIsSubmitting(true); + setError(null); + + // Get files and links from form data + const files = formData.documents || []; + const links = formData['social-links']?.map(link => ({ + type: link.type || 'website', + url: link.url + })) || []; + + // Create project with files and links in one call + const project = await createProject(companyId, formData, files, links); + console.log('Created project:', project); + + // Navigate to success page or dashboard + navigate('/dashboard'); + } catch (err) { + console.error('Failed to submit project:', err); + setError('Failed to submit project. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + const renderField = (field: FormField) => { switch (field.type) { case 'text': @@ -125,7 +246,26 @@ const SubmitProjectPage = () => {
{/* Header with tabs */}
-

Submit a project

+
+

Submit a project

+
+ {!companyId && ( + + )} + +
+
{projectFormSchema.map(step => ( @@ -146,51 +286,56 @@ const SubmitProjectPage = () => {
+ {/* Form content */} - {currentStepData?.sections.map(section => ( -
-
-

{section.title}

- {section.description && ( -

{section.description}

- )} -
+ {error && ( +
+ {error} +
+ )} -
- {section.fields.map(field => renderField(field))} + {currentStepData?.sections.map(section => ( +
+

{section.title}

+ {section.description && ( +

{section.description}

+ )} +
+ {section.fields.map(field => ( +
+ {renderField(field)} +
+ ))}
))} {currentStep === 'A' && ( -
- +
+
)} {currentStep === 'B' && (
)} @@ -203,4 +348,4 @@ const SubmitProjectPage = () => { ); }; -export { SubmitProjectPage }; \ No newline at end of file +export { SubmitProjectPage }; \ No newline at end of file diff --git a/frontend/src/services/company.ts b/frontend/src/services/company.ts new file mode 100644 index 00000000..357d0b40 --- /dev/null +++ b/frontend/src/services/company.ts @@ -0,0 +1,41 @@ +import { getApiUrl, HttpStatusCode } from '@utils'; +import { ApiError } from './errors'; + +interface CreateCompanyResponse { + id: string; + owner_user_id: string; + name: string; + description: string | null; + is_verified: boolean; + created_at: string; + updated_at: string; +} + +export async function createCompany( + ownerUserId: string, + name: string, + description?: string +): Promise { + const url = getApiUrl('/companies'); + const body = { + owner_user_id: ownerUserId, + name, + description + }; + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const json = await res.json(); + + if (res.status !== HttpStatusCode.CREATED) { + throw new ApiError('Failed to create company', res.status, json); + } + + return json as CreateCompanyResponse; +} \ No newline at end of file diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 86eb35d9..58303032 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,2 +1,5 @@ export { register, saveRefreshToken } from './auth'; export { RegisterError, ApiError, API_ERROR, REGISTER_ERROR } from './errors'; +export { createProject } from './project'; +export { createCompany } from './company'; +export { uploadFile } from './storage'; diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts new file mode 100644 index 00000000..1fa4444c --- /dev/null +++ b/frontend/src/services/project.ts @@ -0,0 +1,78 @@ +import { getApiUrl, HttpStatusCode } from '@utils'; +import { ApiError } from './errors'; +import type { FormData } from '@/types'; +import { uploadFile } from './storage'; + +interface CreateProjectResponse { + id: string; + company_id: string; + title: string; + description: string | null; + status: string; + created_at: string; + updated_at: string; +} + +interface ProjectFile { + file_type: string; + file_url: string; +} + +interface ProjectLink { + link_type: string; + url: string; +} + +export async function createProject( + companyId: string, + formData: FormData, + files: File[] = [], + links: { type: string; url: string }[] = [] +): Promise { + // First upload all files + const uploadedFiles: ProjectFile[] = await Promise.all( + files.map(async (file) => { + const fileUrl = await uploadFile(file); + return { + file_type: file.type, + file_url: fileUrl + }; + }) + ); + + // Create project with files and links + const url = getApiUrl('/projects'); + const body = { + company_id: companyId, + title: formData.companyName, + description: formData.description, + status: 'pending', + founded_date: formData.foundedDate, + company_stage: formData.companyStage, + investment_stage: formData.investmentStage, + inspiration: formData.inspiration, + vision: formData.vision, + team_members: formData['team-members'], + files: uploadedFiles, + links: links.map(link => ({ + link_type: link.type, + url: link.url + })) + }; + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const json = await res.json(); + + if (res.status !== HttpStatusCode.CREATED) { + throw new ApiError('Failed to create project', res.status, json); + } + + return json as CreateProjectResponse; +} \ No newline at end of file diff --git a/frontend/src/services/storage.ts b/frontend/src/services/storage.ts new file mode 100644 index 00000000..7ac8946e --- /dev/null +++ b/frontend/src/services/storage.ts @@ -0,0 +1,34 @@ +import { getApiUrl, HttpStatusCode } from '@utils'; +import { ApiError } from './errors'; + +interface UploadResponse { + file_url: string; +} + +export async function uploadFile(file: File): Promise { + const url = getApiUrl('/storage/upload'); + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await fetch(url, { + method: 'POST', + body: formData, + headers: { + 'Accept': 'application/json', + }, + }); + + if (!res.ok) { + const json = await res.json().catch(() => ({ message: 'Upload failed' })); + console.error('Upload failed:', json); + throw new ApiError('Failed to upload file', res.status, json); + } + + const json = await res.json(); + return (json as UploadResponse).file_url; + } catch (err) { + console.error('Upload error:', err); + throw err; + } +} \ No newline at end of file diff --git a/frontend/src/utils/Router.tsx b/frontend/src/utils/Router.tsx index c253ece5..784aac74 100644 --- a/frontend/src/utils/Router.tsx +++ b/frontend/src/utils/Router.tsx @@ -1,31 +1,34 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Landing, Register, DashboardPage, AdminDashboardPage, SubmitProjectPage } from '@pages'; +import { AuthProvider } from '@/contexts/AuthContext'; const Router = () => ( - - - } /> - } /> - - {/* User routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Admin routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + + {/* User routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); export { Router }; \ No newline at end of file