From cf2fedcf996110d249617717e98699333d042616 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sat, 7 Dec 2024 00:10:21 -0500 Subject: [PATCH 01/21] update Dockerfile --- backend/Dockerfile | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 43c3e641..ed4636c5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,15 +1,12 @@ FROM golang:1.23-alpine AS builder + WORKDIR /app -COPY . . + +COPY go.mod go.sum ./ RUN go mod download -RUN CGO_ENABLED=0 GOOS=linux go build -o app +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o app . -FROM alpine:3.20 -RUN apk --no-cache add ca-certificates -WORKDIR /root/ -COPY --from=builder /app/app . -RUN mkdir -p static/dist -EXPOSE 6969 -ENV APP_ENV="production" CMD ["./app"] From 4c8bb2e614fa5d877d3413b8ad47da895679d2cf Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 14:28:08 -0500 Subject: [PATCH 02/21] add protected route component --- .../src/components/layout/ProtectedRoute.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/src/components/layout/ProtectedRoute.tsx diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx new file mode 100644 index 00000000..b6d162b5 --- /dev/null +++ b/frontend/src/components/layout/ProtectedRoute.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '@/contexts/AuthContext'; +import type { UserRole } from '@/types'; + +interface ProtectedRouteProps { + children: React.ReactNode; + allowedRoles: UserRole[]; +} + +export const ProtectedRoute: React.FC = ({ children, allowedRoles }) => { + const { user } = useAuth(); + + // if not logged in, redirect to landing page + if (!user) { + return ; + } + + // check if user's role is allowed + if (!allowedRoles.includes(user.role as UserRole)) { + // redirect to user dashboard if unauthorized + return ; + } + + return <>{children}; +}; \ No newline at end of file From 66805862ffa32110a748922c01b3fe354dabfa90 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 14:28:18 -0500 Subject: [PATCH 03/21] use it --- frontend/src/components/layout/index.ts | 3 + frontend/src/utils/Router.tsx | 85 +++++++++++++++++++++---- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/layout/index.ts b/frontend/src/components/layout/index.ts index 421bd6e2..acc8d770 100644 --- a/frontend/src/components/layout/index.ts +++ b/frontend/src/components/layout/index.ts @@ -14,3 +14,6 @@ export * from './templates/DashboardTemplate'; // Page Layouts export * from './pages/AdminDashboard'; export * from './pages/UserDashboard'; + +// Protected Route +export { ProtectedRoute } from './ProtectedRoute'; diff --git a/frontend/src/utils/Router.tsx b/frontend/src/utils/Router.tsx index b34a8843..ba94eac1 100644 --- a/frontend/src/utils/Router.tsx +++ b/frontend/src/utils/Router.tsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Landing, Register, DashboardPage, AdminDashboardPage, SubmitProjectPage } from '@pages'; import { AuthProvider } from '@/contexts/AuthContext'; +import { ProtectedRoute } from '@/components/layout/ProtectedRoute'; const Router = () => ( @@ -10,22 +11,78 @@ const Router = () => ( } /> {/* User routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> {/* Admin routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> + + + + } /> From 0a89fc038374af5b110570dd94c3c5da21f5230c Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 14:30:49 -0500 Subject: [PATCH 04/21] role specific redirects actually --- .../src/components/layout/ProtectedRoute.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx index b6d162b5..bbf25111 100644 --- a/frontend/src/components/layout/ProtectedRoute.tsx +++ b/frontend/src/components/layout/ProtectedRoute.tsx @@ -18,8 +18,23 @@ export const ProtectedRoute: React.FC = ({ children, allowe // check if user's role is allowed if (!allowedRoles.includes(user.role as UserRole)) { - // redirect to user dashboard if unauthorized - return ; + // determine appropriate redirect based on user's role + let redirectPath = '/'; + + switch (user.role) { + case 'startup_owner': + case 'investor': + redirectPath = '/dashboard'; + break; + case 'admin': + redirectPath = '/admin/dashboard'; + break; + default: + // if unknown role, redirect to landing + redirectPath = '/'; + } + + return ; } return <>{children}; From e05e6ed2ec7c536cf38b86331a3178f84b97705e Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 14:47:32 -0500 Subject: [PATCH 05/21] yeet --- backend/internal/server/auth.go | 194 +++++++++++++++++++++------ backend/internal/server/auth_test.go | 132 +++++++++++++++++- backend/internal/server/index.go | 29 +++- frontend/src/pages/Register.tsx | 13 +- frontend/src/services/auth.ts | 78 +++++++++-- 5 files changed, 383 insertions(+), 63 deletions(-) diff --git a/backend/internal/server/auth.go b/backend/internal/server/auth.go index e3deab57..e25cf867 100644 --- a/backend/internal/server/auth.go +++ b/backend/internal/server/auth.go @@ -12,12 +12,31 @@ import ( "KonferCA/SPUR/internal/service" "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v4" "github.com/rs/zerolog/log" "golang.org/x/crypto/bcrypt" ) +type SignupResponse struct { + AccessToken string `json:"access_token"` + User UserResponse `json:"user"` +} + +type SigninResponse struct { + AccessToken string `json:"access_token"` + User UserResponse `json:"user"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Role db.UserRole `json:"role"` + WalletAddress *string `json:"wallet_address,omitempty"` + EmailVerified bool `json:"email_verified"` +} + func (s *Server) setupAuthRoutes() { auth := s.apiV1.Group("/auth") auth.Use(s.authLimiter.RateLimit()) // special rate limit for auth routes @@ -25,13 +44,14 @@ func (s *Server) setupAuthRoutes() { auth.POST("/signin", s.handleSignin, mw.ValidateRequestBody(reflect.TypeOf(SigninRequest{}))) auth.GET("/verify-email", s.handleVerifyEmail) auth.GET("/ami-verified", s.handleEmailVerifiedStatus) + auth.POST("/refresh", s.handleRefreshToken) + auth.POST("/signout", s.handleSignout) } func (s *Server) handleSignup(c echo.Context) error { var req *SignupRequest req, ok := c.Get(mw.REQUEST_BODY_KEY).(*SignupRequest) if !ok { - // not good... no bueno return echo.NewHTTPError(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)) } @@ -39,27 +59,23 @@ func (s *Server) handleSignup(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - ctx := c.Request().Context() - existingUser, err := s.queries.GetUserByEmail(ctx, req.Email) - if err == nil && existingUser.ID != "" { - return echo.NewHTTPError(http.StatusConflict, "email already registered") - } - + // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to hash password") } + ctx := context.Background() user, err := s.queries.CreateUser(ctx, db.CreateUserParams{ Email: req.Email, PasswordHash: string(hashedPassword), Role: req.Role, }) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to create user") + return echo.NewHTTPError(http.StatusConflict, "email already exists") } - // Get user's token salt + // Get user's token salt that was generated during creation salt, err := s.queries.GetUserTokenSalt(ctx, user.ID) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user's token salt") @@ -67,42 +83,51 @@ func (s *Server) handleSignup(c echo.Context) error { accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, salt) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate token") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate tokens") } - // send verification email - // db pool is passed to not lose reference to the s object once - // the function returns the response. - go func(pool *pgxpool.Pool, email string) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) - defer cancel() - q := db.New(pool) - exp := time.Now().Add(time.Minute * 30) - token, err := q.CreateVerifyEmailToken(ctx, db.CreateVerifyEmailTokenParams{ - Email: email, - // default expires after 30 minutes - ExpiresAt: exp, + // Set refresh token as HTTP-only cookie + cookie := new(http.Cookie) + cookie.Name = "refresh_token" + cookie.Value = refreshToken + cookie.HttpOnly = true + cookie.Secure = true // only send over HTTPS + cookie.SameSite = http.SameSiteStrictMode + cookie.Path = "/api/v1/auth" // only accessible by auth endpoints + cookie.MaxAge = 7 * 24 * 60 * 60 // 7 days in seconds + + c.SetCookie(cookie) + + // Send verification email asynchronously + go func() { + token, err := s.queries.CreateVerifyEmailToken(context.Background(), db.CreateVerifyEmailTokenParams{ + Email: user.Email, + ExpiresAt: time.Now().Add(30 * time.Minute), }) if err != nil { - log.Error().Err(err).Str("email", email).Msg("Failed to create verify email token in db.") + log.Error().Err(err).Msg("Failed to create verification token") return } - tokenStr, err := jwt.GenerateVerifyEmailToken(email, token.ID, exp) + + // Generate JWT for email verification + tokenStr, err := jwt.GenerateVerifyEmailToken(token.Email, token.ID, token.ExpiresAt) if err != nil { - log.Error().Err(err).Str("email", email).Msg("Failed to generate signed verify email token.") + log.Error().Err(err).Msg("Failed to generate verification token") return } - err = service.SendVerficationEmail(ctx, email, tokenStr) - if err != nil { - log.Error().Err(err).Str("email", email).Msg("Failed to send verification email.") + + // Send verification email + if err := service.SendVerficationEmail(context.Background(), user.Email, tokenStr); err != nil { + log.Error().Err(err).Msg("Failed to send verification email") return } - }(s.DBPool, user.Email) - return c.JSON(http.StatusCreated, AuthResponse{ - AccessToken: accessToken, - RefreshToken: refreshToken, - User: User{ + log.Info().Str("token_id", token.ID).Msg("Verification email sent") + }() + + return c.JSON(http.StatusCreated, SignupResponse{ + AccessToken: accessToken, + User: UserResponse{ ID: user.ID, Email: user.Email, FirstName: user.FirstName, @@ -138,13 +163,24 @@ func (s *Server) handleSignin(c echo.Context) error { accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, salt) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate token") + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate tokens") } - return c.JSON(http.StatusOK, AuthResponse{ - AccessToken: accessToken, - RefreshToken: refreshToken, - User: User{ + // Set refresh token as HTTP-only cookie + cookie := new(http.Cookie) + cookie.Name = "refresh_token" + cookie.Value = refreshToken + cookie.HttpOnly = true + cookie.Secure = true // only send over HTTPS + cookie.SameSite = http.SameSiteStrictMode + cookie.Path = "/api/v1/auth" // only accessible by auth endpoints + cookie.MaxAge = 7 * 24 * 60 * 60 // 7 days in seconds + + c.SetCookie(cookie) + + return c.JSON(http.StatusOK, SigninResponse{ + AccessToken: accessToken, + User: UserResponse{ ID: user.ID, Email: user.Email, FirstName: user.FirstName, @@ -248,6 +284,86 @@ func (s *Server) handleEmailVerifiedStatus(c echo.Context) error { return c.JSON(http.StatusOK, EmailVerifiedStatusResponse{Verified: user.EmailVerified}) } +func (s *Server) handleRefreshToken(c echo.Context) error { + // Get refresh token from HTTP-only cookie + cookie, err := c.Cookie("refresh_token") + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "no refresh token provided") + } + + // Parse claims without verification to get userID + unverifiedClaims, err := jwt.ParseUnverifiedClaims(cookie.Value) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token format") + } + + // Get user's salt from database + ctx := context.Background() + salt, err := s.queries.GetUserTokenSalt(ctx, unverifiedClaims.UserID) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + // Verify the refresh token with user's salt + claims, err := jwt.VerifyTokenWithSalt(cookie.Value, salt) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + // Verify it's actually a refresh token + if claims.TokenType != jwt.REFRESH_TOKEN_TYPE { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid token type") + } + + // Update user's token salt to invalidate old tokens + if err := s.queries.UpdateUserTokenSalt(ctx, claims.UserID); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to rotate token salt") + } + + // Get the new salt + newSalt, err := s.queries.GetUserTokenSalt(ctx, claims.UserID) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get new token salt") + } + + // Generate new tokens with the new salt + accessToken, refreshToken, err := jwt.GenerateWithSalt(claims.UserID, claims.Role, newSalt) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate tokens") + } + + // Set new refresh token cookie + refreshCookie := new(http.Cookie) + refreshCookie.Name = "refresh_token" + refreshCookie.Value = refreshToken + refreshCookie.HttpOnly = true + refreshCookie.Secure = true + refreshCookie.SameSite = http.SameSiteStrictMode + refreshCookie.Path = "/api/v1/auth" + refreshCookie.MaxAge = 7 * 24 * 60 * 60 // 7 days + + c.SetCookie(refreshCookie) + + return c.JSON(http.StatusOK, map[string]string{ + "access_token": accessToken, + }) +} + +func (s *Server) handleSignout(c echo.Context) error { + // Create an expired cookie to clear the refresh token + cookie := new(http.Cookie) + cookie.Name = "refresh_token" + cookie.Value = "" + cookie.HttpOnly = true + cookie.Secure = true + cookie.SameSite = http.SameSiteStrictMode + cookie.Path = "/api/v1/auth" + cookie.MaxAge = -1 // immediately expires the cookie + + c.SetCookie(cookie) + return c.NoContent(http.StatusOK) +} + // helper function to convert pgtype.Text to *string func getStringPtr(t pgtype.Text) *string { if !t.Valid { diff --git a/backend/internal/server/auth_test.go b/backend/internal/server/auth_test.go index 69dcb015..f6288ad1 100644 --- a/backend/internal/server/auth_test.go +++ b/backend/internal/server/auth_test.go @@ -57,12 +57,23 @@ func TestAuth(t *testing.T) { assert.Equal(t, http.StatusCreated, rec.Code) - var response AuthResponse + var response SignupResponse err := json.NewDecoder(rec.Body).Decode(&response) assert.NoError(t, err) assert.NotEmpty(t, response.AccessToken) - assert.NotEmpty(t, response.RefreshToken) assert.Equal(t, payload.Email, response.User.Email) + + // Check refresh token cookie + cookies := rec.Result().Cookies() + var refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "refresh_token" { + refreshCookie = cookie + break + } + } + assert.NotNil(t, refreshCookie, "Refresh token cookie should be set") + assert.True(t, refreshCookie.HttpOnly, "Cookie should be HTTP-only") }) // test duplicate email @@ -97,12 +108,23 @@ func TestAuth(t *testing.T) { s.echoInstance.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) - var response AuthResponse + var response SigninResponse err := json.Unmarshal(rec.Body.Bytes(), &response) assert.NoError(t, err) assert.NotEmpty(t, response.AccessToken) - assert.NotEmpty(t, response.RefreshToken) assert.Equal(t, payload.Email, response.User.Email) + + // Check refresh token cookie + cookies := rec.Result().Cookies() + var refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "refresh_token" { + refreshCookie = cookie + break + } + } + assert.NotNil(t, refreshCookie, "Refresh token cookie should be set") + assert.True(t, refreshCookie.HttpOnly, "Cookie should be HTTP-only") }) // test invalid credentials @@ -188,4 +210,106 @@ func TestAuth(t *testing.T) { s.echoInstance.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) }) + + // test refresh token endpoint + t.Run("refresh token", func(t *testing.T) { + // First sign in to get a refresh token cookie + signinPayload := SigninRequest{ + Email: "test@example.com", + Password: "password123", + } + body, _ := json.Marshal(signinPayload) + + signinReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signin", bytes.NewReader(body)) + signinReq.Header.Set("Content-Type", "application/json") + signinRec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(signinRec, signinReq) + assert.Equal(t, http.StatusOK, signinRec.Code) + + // Get the refresh token cookie + cookies := signinRec.Result().Cookies() + var refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "refresh_token" { + refreshCookie = cookie + break + } + } + assert.NotNil(t, refreshCookie, "Refresh token cookie should be set") + assert.True(t, refreshCookie.HttpOnly, "Cookie should be HTTP-only") + assert.True(t, refreshCookie.Secure, "Cookie should be secure") + assert.Equal(t, http.SameSiteStrictMode, refreshCookie.SameSite, "Cookie should have strict same-site policy") + assert.Equal(t, "/api/v1/auth", refreshCookie.Path, "Cookie should be limited to auth endpoints") + + // Test refresh endpoint + refreshReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) + refreshReq.AddCookie(refreshCookie) + refreshRec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(refreshRec, refreshReq) + assert.Equal(t, http.StatusOK, refreshRec.Code) + + var refreshResponse map[string]string + err := json.NewDecoder(refreshRec.Body).Decode(&refreshResponse) + assert.NoError(t, err) + assert.NotEmpty(t, refreshResponse["access_token"], "Should return new access token") + + // Verify new refresh token cookie is set + newCookies := refreshRec.Result().Cookies() + var newRefreshCookie *http.Cookie + for _, cookie := range newCookies { + if cookie.Name == "refresh_token" { + newRefreshCookie = cookie + break + } + } + assert.NotNil(t, newRefreshCookie, "New refresh token cookie should be set") + assert.NotEqual(t, refreshCookie.Value, newRefreshCookie.Value, "New refresh token should be different") + }) + + // test signout endpoint + t.Run("signout", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signout", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + // Check that refresh token cookie is cleared + cookies := rec.Result().Cookies() + var refreshCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "refresh_token" { + refreshCookie = cookie + break + } + } + assert.NotNil(t, refreshCookie, "Refresh token cookie should be present") + assert.Equal(t, "", refreshCookie.Value, "Cookie value should be empty") + assert.True(t, refreshCookie.MaxAge < 0, "Cookie should be expired") + }) + + // test refresh with invalid token + t.Run("refresh with invalid token", func(t *testing.T) { + invalidCookie := &http.Cookie{ + Name: "refresh_token", + Value: "invalid-token", + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) + req.AddCookie(invalidCookie) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + // test refresh without token + t.Run("refresh without token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil) + rec := httptest.NewRecorder() + + s.echoInstance.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) } diff --git a/backend/internal/server/index.go b/backend/internal/server/index.go index c0603a01..7baf04cd 100644 --- a/backend/internal/server/index.go +++ b/backend/internal/server/index.go @@ -121,8 +121,13 @@ func New(testing bool) (*Server, error) { echo.HeaderContentType, echo.HeaderAccept, echo.HeaderContentLength, + echo.HeaderAuthorization, "X-Request-ID", }, + AllowCredentials: true, + ExposeHeaders: []string{ + "Set-Cookie", + }, })) } else if os.Getenv("APP_ENV") == common.STAGING_ENV { e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ @@ -133,12 +138,32 @@ func New(testing bool) (*Server, error) { echo.HeaderContentType, echo.HeaderAccept, echo.HeaderContentLength, + echo.HeaderAuthorization, "X-Request-ID", }, + AllowCredentials: true, + ExposeHeaders: []string{ + "Set-Cookie", + }, })) } else { - // use default cors middleware for development - e.Use(echoMiddleware.CORS()) + // development environment + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ + AllowOrigins: []string{"http://localhost:5173"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{ + echo.HeaderOrigin, + echo.HeaderContentType, + echo.HeaderAccept, + echo.HeaderContentLength, + echo.HeaderAuthorization, + "X-Request-ID", + }, + AllowCredentials: true, + ExposeHeaders: []string{ + "Set-Cookie", + }, + })) } e.Use(middleware.Logger()) diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 98252345..e18cb14a 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, FormEvent } from 'react'; import { Button, TextInput, TextArea } from '@components'; -import { register, RegisterError, saveRefreshToken } from '@services'; +import { register, RegisterError } from '@services'; import { useAuth } from '@/contexts/AuthContext'; type RegistrationStep = @@ -27,8 +27,8 @@ interface FormErrors { } const Register = () => { - const [currentStep, setCurrentStep] = - useState('login-register'); + const { setUser, setCompanyId } = useAuth(); + const [currentStep, setCurrentStep] = useState('login-register'); const [formData, setFormData] = useState({ firstName: '', lastName: '', @@ -39,7 +39,6 @@ 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]+)\/?$/; @@ -90,13 +89,9 @@ const Register = () => { e.preventDefault(); try { const regResp = await register(formData.email, formData.password); - console.log(regResp); - + setUser(regResp.user); - saveRefreshToken(regResp.refreshToken); - setCompanyId('mock-company-id'); - setCurrentStep('verify-email'); } catch (error) { if (error instanceof RegisterError) { diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 3b0cf3b1..e94a514e 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -7,9 +7,13 @@ import { RegisterError } from './errors'; import type { User, UserRole } from '@t'; -export interface RegisterReponse { +export interface RegisterResponse { + accessToken: string; + user: User; +} + +export interface SigninResponse { accessToken: string; - refreshToken: string; user: User; } @@ -20,7 +24,7 @@ export async function register( email: string, password: string, role: UserRole = 'startup_owner' -): Promise { +): Promise { const url = getApiUrl('/auth/signup'); const body = { email, @@ -34,21 +38,77 @@ export async function register( headers: { 'Content-Type': 'application/json', }, + credentials: 'include', // needed for cookies }); - // the backend should always return json for the api calls + const json = await res.json(); if (res.status !== HttpStatusCode.CREATED) { throw new RegisterError('Failed to register', res.status, json); } - return json as RegisterReponse; + return json as RegisterResponse; } /** - * Saves the refresh token in localStorage. + * Signs in a user with email and password. */ -export function saveRefreshToken(refreshToken: string) { - // IMPORTANT: The location on where the token is saved must be revisited. - localStorage.setItem('refresh_token', refreshToken); +export async function signin( + email: string, + password: string +): Promise { + const url = getApiUrl('/auth/signin'); + const body = { + email, + password, + }; + + const res = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // needed for cookies + }); + + const json = await res.json(); + + if (res.status !== HttpStatusCode.OK) { + throw new RegisterError('Failed to sign in', res.status, json); + } + + return json as SigninResponse; +} + +/** + * Refreshes the access token using the refresh token stored in HTTP-only cookie. + * Returns the new access token if successful. + */ +export async function refreshAccessToken(): Promise { + const url = getApiUrl('/auth/refresh'); + + const res = await fetch(url, { + method: 'POST', + credentials: 'include', // needed for cookies + }); + + if (!res.ok) { + throw new Error('Failed to refresh access token'); + } + + const json = await res.json(); + return json.access_token; +} + +/** + * Signs out the user by clearing the refresh token cookie. + */ +export async function signout(): Promise { + const url = getApiUrl('/auth/signout'); + + await fetch(url, { + method: 'POST', + credentials: 'include', + }); } From b20d936444bcfbc56362fc90fe4b05aeedfa16b8 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:02:44 -0500 Subject: [PATCH 06/21] Seed test accounts --- .../20241221000001_update_seed_accounts.sql | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql diff --git a/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql b/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql new file mode 100644 index 00000000..bb7b5802 --- /dev/null +++ b/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql @@ -0,0 +1,72 @@ +-- +goose Up +-- +goose StatementBegin + +-- clean up any existing seed accounts +DELETE FROM users WHERE email IN ('admin@spur.com', 'startup@test.com', 'investor@test.com'); + +-- create admin user +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'admin@spur.com', + -- hash for 'admin123' + '$2a$10$jltnaECAYSCQozp5UNZi7OZQlyuTR3sJFj5Hr1nLEVmI9uSAxDKnq', + 'Admin', + 'User', + 'admin', + true, + gen_random_bytes(32) +); + +-- create startup owner +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'startup@test.com', + -- hash for 'startup123' + '$2a$10$Cu72xg8m59GjDHKiFzK7pO8rLYjFL7XsPD6YezNkyZw8ItZBSnvfy', + 'Startup', + 'Owner', + 'startup_owner', + true, + gen_random_bytes(32) +); + +-- create investor +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'investor@test.com', + -- hash for 'investor123' + '$2a$10$/7Mq7D4hlh0zisjOryL.KeeWSUU30tL5mJdYLAjcqeOodSPrbB.hK', + 'Test', + 'Investor', + 'investor', + true, + gen_random_bytes(32) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DELETE FROM users WHERE email IN ('admin@spur.com', 'startup@test.com', 'investor@test.com'); +-- +goose StatementEnd \ No newline at end of file From 118a99154ccd1cfd7322e6dff311cefc008fd982 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:03:02 -0500 Subject: [PATCH 07/21] Hash generator --- backend/scripts/generate_password_hash.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 backend/scripts/generate_password_hash.go diff --git a/backend/scripts/generate_password_hash.go b/backend/scripts/generate_password_hash.go new file mode 100644 index 00000000..305d6ab3 --- /dev/null +++ b/backend/scripts/generate_password_hash.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "golang.org/x/crypto/bcrypt" + "log" +) + +func main() { + passwords := []string{"admin123", "startup123", "investor123"} + + for _, password := range passwords { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("failed to hash password: %v", err) + } + + fmt.Printf("Password: %s\nHash: %s\n\n", password, string(hash)) + } +} \ No newline at end of file From a8f7a5308687ed37976269cbfacebab738c5b224 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 17:05:55 -0500 Subject: [PATCH 08/21] Implement sign in and fix auth --- frontend/src/contexts/AuthContext.tsx | 55 ++++++++------------ frontend/src/pages/Register.tsx | 72 +++++++++++++++++++++++---- frontend/src/services/auth.ts | 52 +++++-------------- frontend/src/services/index.ts | 2 +- 4 files changed, 97 insertions(+), 84 deletions(-) diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 088334e0..f5f3689b 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,11 +1,12 @@ -import React, { createContext, useContext, useState, useEffect } from 'react'; +import React, { createContext, useContext, useState } 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; + accessToken: string | null; + setAuth: (user: User | null, token: string | null, companyId?: string | null) => void; + clearAuth: () => void; } const AuthContext = createContext(undefined); @@ -13,40 +14,28 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null); const [companyId, setCompanyId] = useState(null); + const [accessToken, setAccessToken] = 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); - } - }, []); + const setAuth = (user: User | null, token: string | null, companyId: string | null = null) => { + setUser(user); + setAccessToken(token); + setCompanyId(companyId); + }; - // 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]); + const clearAuth = () => { + setUser(null); + setAccessToken(null); + setCompanyId(null); + }; return ( - + {children} ); diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index e18cb14a..6dbb768b 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, FormEvent } from 'react'; import { Button, TextInput, TextArea } from '@components'; -import { register, RegisterError } from '@services'; +import { register, signin, RegisterError, ApiError } from '@services'; import { useAuth } from '@/contexts/AuthContext'; +import { useNavigate } from 'react-router-dom'; type RegistrationStep = | 'login-register' @@ -27,7 +28,8 @@ interface FormErrors { } const Register = () => { - const { setUser, setCompanyId } = useAuth(); + const navigate = useNavigate(); + const { setAuth } = useAuth(); const [currentStep, setCurrentStep] = useState('login-register'); const [formData, setFormData] = useState({ firstName: '', @@ -39,6 +41,7 @@ const Register = () => { password: '', }); const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); const LINKEDIN_REGEX = /^(https?:\/\/)?([\w]+\.)?linkedin\.com\/(pub|in|profile)\/([-a-zA-Z0-9]+)\/?$/; @@ -87,18 +90,56 @@ const Register = () => { const handleInitialSubmit = async (e: FormEvent) => { e.preventDefault(); + setIsLoading(true); try { const regResp = await register(formData.email, formData.password); - - setUser(regResp.user); - setCompanyId('mock-company-id'); + setAuth(regResp.user, regResp.accessToken, 'mock-company-id'); setCurrentStep('verify-email'); } catch (error) { if (error instanceof RegisterError) { - console.log('do something here', error.statusCode, error.body); + setErrors(prev => ({ + ...prev, + email: error.body.message || 'Registration failed' + })); + } else { + setErrors(prev => ({ + ...prev, + email: 'An unexpected error occurred' + })); + } + } finally { + setIsLoading(false); + } + }; + + const handleLogin = async () => { + setIsLoading(true); + try { + const signinResp = await signin(formData.email, formData.password); + setAuth(signinResp.user, signinResp.accessToken); + + // Redirect based on user role + if (signinResp.user.role === 'admin') { + navigate('/admin/dashboard'); + } else if (signinResp.user.role === 'startup_owner') { + navigate('/dashboard'); + } else if (signinResp.user.role === 'investor') { + navigate('/dashboard'); // or a specific investor dashboard + } + } catch (error) { + if (error instanceof ApiError) { + setErrors(prev => ({ + ...prev, + email: 'Invalid email or password' + })); } else { - // TODO: handle error with some kind of notification + setErrors(prev => ({ + ...prev, + email: 'An unexpected error occurred' + })); } + } finally { + setIsLoading(false); } }; @@ -134,6 +175,7 @@ const Register = () => { name="email" value={formData.email} onChange={handleChange} + error={errors.email} /> { name="password" value={formData.password} onChange={handleChange} + error={errors.password} /> -
@@ -155,9 +204,10 @@ const Register = () => { type="button" liquid size="lg" - // TODO: onClick to handle login + onClick={handleLogin} + disabled={isLoading} > - Login + {isLoading ? 'Please wait...' : 'Login'}
diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index e94a514e..4b57ab8e 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -3,19 +3,17 @@ */ import { getApiUrl, HttpStatusCode } from '@utils'; -import { RegisterError } from './errors'; +import { RegisterError, ApiError } from './errors'; import type { User, UserRole } from '@t'; -export interface RegisterResponse { +export interface AuthResponse { accessToken: string; user: User; } -export interface SigninResponse { - accessToken: string; - user: User; -} +export interface RegisterReponse extends AuthResponse {} +export interface SigninResponse extends AuthResponse {} /** * Registers a user if the given email is not already registered. @@ -24,7 +22,7 @@ export async function register( email: string, password: string, role: UserRole = 'startup_owner' -): Promise { +): Promise { const url = getApiUrl('/auth/signup'); const body = { email, @@ -38,20 +36,19 @@ export async function register( headers: { 'Content-Type': 'application/json', }, - credentials: 'include', // needed for cookies }); - + // the backend should always return json for the api calls const json = await res.json(); if (res.status !== HttpStatusCode.CREATED) { throw new RegisterError('Failed to register', res.status, json); } - return json as RegisterResponse; + return json as RegisterReponse; } /** - * Signs in a user with email and password. + * Signs in a user with email and password */ export async function signin( email: string, @@ -69,46 +66,23 @@ export async function signin( headers: { 'Content-Type': 'application/json', }, - credentials: 'include', // needed for cookies }); const json = await res.json(); if (res.status !== HttpStatusCode.OK) { - throw new RegisterError('Failed to sign in', res.status, json); + throw new ApiError('Failed to sign in', res.status, json); } return json as SigninResponse; } /** - * Refreshes the access token using the refresh token stored in HTTP-only cookie. - * Returns the new access token if successful. - */ -export async function refreshAccessToken(): Promise { - const url = getApiUrl('/auth/refresh'); - - const res = await fetch(url, { - method: 'POST', - credentials: 'include', // needed for cookies - }); - - if (!res.ok) { - throw new Error('Failed to refresh access token'); - } - - const json = await res.json(); - return json.access_token; -} - -/** - * Signs out the user by clearing the refresh token cookie. + * Signs out the current user by: + * 1. Calling the signout endpoint to clear the refresh token cookie + * 2. Clearing the auth context */ export async function signout(): Promise { const url = getApiUrl('/auth/signout'); - - await fetch(url, { - method: 'POST', - credentials: 'include', - }); + await fetch(url, { method: 'POST' }); } diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 58303032..68d3232e 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -1,4 +1,4 @@ -export { register, saveRefreshToken } from './auth'; +export { register, signin, signout } from './auth'; export { RegisterError, ApiError, API_ERROR, REGISTER_ERROR } from './errors'; export { createProject } from './project'; export { createCompany } from './company'; From a6b8549c35e3f2bf6a5ff1241db988cf99447bba Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:59:43 -0500 Subject: [PATCH 09/21] seed demo companies --- .../20241221000002_seed_demo_companies.sql | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql diff --git a/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql b/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql new file mode 100644 index 00000000..de950a0e --- /dev/null +++ b/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql @@ -0,0 +1,165 @@ +-- +goose Up +-- +goose StatementBegin + +-- get the startup owner's user id +WITH startup_user AS ( + SELECT id FROM users WHERE email = 'startup@test.com' LIMIT 1 +) +-- create demo companies +INSERT INTO companies ( + id, + owner_user_id, + name, + description, + is_verified, + created_at, + updated_at +) +SELECT + gen_random_uuid(), + startup_user.id, + name, + description, + is_verified, + created_at, + updated_at +FROM startup_user, (VALUES + ( + 'TechVision AI', + 'An AI company focusing on computer vision solutions for autonomous vehicles', + true, + NOW() - INTERVAL '30 days', + NOW() - INTERVAL '2 days' + ), + ( + 'GreenEnergy Solutions', + 'Developing innovative solar panel technology for residential use', + true, + NOW() - INTERVAL '60 days', + NOW() - INTERVAL '5 days' + ), + ( + 'HealthTech Pro', + 'Healthcare technology focusing on remote patient monitoring', + false, + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '1 day' + ), + ( + 'EduLearn Platform', + 'Online education platform with AI-powered personalized learning', + true, + NOW() - INTERVAL '90 days', + NOW() - INTERVAL '10 days' + ), + ( + 'FinTech Solutions', + 'Blockchain-based payment solutions for cross-border transactions', + false, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '2 days' + ) +) AS t(name, description, is_verified, created_at, updated_at); + +-- Add some company financials +WITH companies_to_update AS ( + SELECT id, name FROM companies + WHERE name IN ('TechVision AI', 'GreenEnergy Solutions', 'EduLearn Platform') +) +INSERT INTO company_financials ( + company_id, + financial_year, + revenue, + expenses, + profit, + sales, + amount_raised, + arr, + grants_received +) +SELECT + id, + 2023, + 1000000.00, -- revenue + 800000.00, -- expenses + 200000.00, -- profit + 1200000.00, -- sales + 500000.00, -- amount raised + 960000.00, -- arr + 50000.00 -- grants +FROM companies_to_update; + +-- Add some employees +WITH companies_to_update AS ( + SELECT id, name FROM companies + WHERE name IN ('TechVision AI', 'GreenEnergy Solutions') +) +INSERT INTO employees ( + company_id, + name, + email, + role, + bio +) +SELECT + c.id, + e.name, + e.email, + e.role, + e.bio +FROM companies_to_update c +CROSS JOIN (VALUES + ( + 'John Smith', + 'john@techvision.ai', + 'CTO', + 'Experienced AI researcher with 10+ years in computer vision' + ), + ( + 'Sarah Johnson', + 'sarah@techvision.ai', + 'Lead Engineer', + 'Senior software engineer specializing in deep learning' + ), + ( + 'Michael Green', + 'michael@greenenergy.com', + 'CEO', + 'Serial entrepreneur with background in renewable energy' + ), + ( + 'Lisa Chen', + 'lisa@greenenergy.com', + 'Head of R&D', + 'PhD in Material Science with focus on solar technology' + ) +) AS e(name, email, role, bio) +WHERE + (c.name = 'TechVision AI' AND e.email LIKE '%techvision%') OR + (c.name = 'GreenEnergy Solutions' AND e.email LIKE '%greenenergy%'); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Delete seeded employees +DELETE FROM employees +WHERE email IN ( + 'john@techvision.ai', + 'sarah@techvision.ai', + 'michael@greenenergy.com', + 'lisa@greenenergy.com' +); + +-- Delete seeded financials and companies +DELETE FROM companies +WHERE name IN ( + 'TechVision AI', + 'GreenEnergy Solutions', + 'HealthTech Pro', + 'EduLearn Platform', + 'FinTech Solutions' +); + +-- +goose StatementEnd \ No newline at end of file From a4b0ee8d4a42ec88a0354b54228afb00729f538f Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 14:16:55 -0500 Subject: [PATCH 10/21] only load .env in development mode --- backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main.go b/backend/main.go index 9db4f4f8..ff206057 100644 --- a/backend/main.go +++ b/backend/main.go @@ -15,7 +15,7 @@ import ( func main() { zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - if os.Getenv("APP_ENV") != common.PRODUCTION_ENV { + if os.Getenv("APP_ENV") == common.DEVELOPMENT_ENV { if err := godotenv.Load(); err != nil { log.Fatal().Err(err).Msg("failed to load .env") } From c0f48a97fa5a45d02c9382442af912ba106a96c9 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 14:17:03 -0500 Subject: [PATCH 11/21] update Dockerfile --- backend/Dockerfile | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index ed4636c5..8685e3f3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,14 @@ FROM golang:1.23-alpine AS builder - WORKDIR /app - -COPY go.mod go.sum ./ +COPY . . RUN go mod download -COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o app -RUN CGO_ENABLED=0 GOOS=linux go build -o app . +FROM alpine:3.20 +RUN apk --no-cache add ca-certificates +WORKDIR /root/ +COPY --from=builder /app/app . +RUN mkdir -p static/dist CMD ["./app"] From 4f44d01f58c9932e690946702cd2347324d42dc7 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 14:17:13 -0500 Subject: [PATCH 12/21] update preview deploy to staging --- .github/workflows/deploy-preview-backend.yml | 56 ------ .github/workflows/deploy-staging-backend.yml | 180 +++++++++++++++++++ 2 files changed, 180 insertions(+), 56 deletions(-) delete mode 100644 .github/workflows/deploy-preview-backend.yml create mode 100644 .github/workflows/deploy-staging-backend.yml diff --git a/.github/workflows/deploy-preview-backend.yml b/.github/workflows/deploy-preview-backend.yml deleted file mode 100644 index 4f52b263..00000000 --- a/.github/workflows/deploy-preview-backend.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Deploy Preview -on: - workflow_dispatch: - workflow_run: - workflows: ["Test Backend"] - types: - - completed - branches: ['main'] - paths: - - 'backend/**' -jobs: - deploy: - runs-on: ubuntu-latest - defaults: - run: - shell: bash - working-directory: backend - steps: - - name: Deploy to Preview Server - uses: appleboy/ssh-action@v1.0.3 - env: - MICONFIG: ${{ secrets.MICONFIG_PREVIEW }} - PK: ${{ secrets.BENTO_PK_PREVIEW }} - with: - host: ${{ secrets.PREVIEW_SERVER_IP }} - username: ${{ secrets.PREVIEW_USER }} - key: ${{ secrets.PREVIEW_SERVER_SSH_KEY }} - envs: MICONFIG,PK - script: | - clean_up() { - echo "Performing cleanup..." - cd ~ - echo "Remove repository" - rm -rf repo - echo "Docker cleanup" - docker system prune -f - echo "Done: Cleanup" - } - handle_error() { - clean_up - echo "Failed to deploy preview" - exit 1 - } - trap 'handle_error' ERR - echo "Cloning repository" - cd ~ - rm -rf repo - git clone git@github.com:${{ github.repository }}.git repo - cd repo - cd backend - echo "Copy deploy preview script" - cp ../.github/scripts/deploy-preview.sh . - echo "Running deploy-preview.sh" - chmod +x deploy-preview.sh - ./deploy-preview.sh - clean_up diff --git a/.github/workflows/deploy-staging-backend.yml b/.github/workflows/deploy-staging-backend.yml new file mode 100644 index 00000000..765e09eb --- /dev/null +++ b/.github/workflows/deploy-staging-backend.yml @@ -0,0 +1,180 @@ +name: Deploy Staging +on: + workflow_dispatch: + push: + branches: ['main'] + paths: + - 'backend/**' +jobs: + deploy: + runs-on: ubuntu-latest + environment: staging + defaults: + run: + shell: bash + working-directory: backend + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create env file + run: | + cat << EOF > .env + APP_ENV=${{ secrets.APP_ENV }} + APP_NAME=${{ secrets.APP_NAME }} + AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_REGION=${{ secrets.AWS_REGION }} + AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }} + AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }} + BACKEND_URL=${{ secrets.BACKEND_URL }} + DB_HOST=${{ secrets.DB_HOST }} + DB_NAME=${{ secrets.DB_NAME }} + DB_PASSWORD=${{ secrets.DB_PASSWORD }} + DB_PORT=${{ secrets.DB_PORT }} + DB_SSLMODE=${{ secrets.DB_SSLMODE }} + DB_USER=${{ secrets.DB_USER }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + JWT_SECRET_VERIFY_EMAIL=${{ secrets.JWT_SECRET_VERIFY_EMAIL }} + NOREPLY_EMAIL=${{ secrets.NOREPLY_EMAIL }} + PORT=${{ secrets.PORT }} + POSTGRES_USER=${{ secrets.DB_USER }} + POSTGRES_DB=${{ secrets.DB_NAME }} + POSTGRES_PASSWORD=${{ secrets.DB_PASSWORD }} + RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} + EOF + + - name: Setup VPS fingerprint + run: | + mkdir -p ~/.ssh + echo "${{ secrets.VPS_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + echo "${{ secrets.VPS_IP }} ${{ secrets.VPS_FINGERPRINT }}" >> ~/.ssh/known_hosts + + - name: Setup Rsync + uses: GuillaumeFalourd/setup-rsync@v1.2 + + - name: Setup VPS File System Tree + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_IP }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_KEY }} + script: | + mkdir -p $HOME/staging/migrations + + - name: Upload .env + run: | + rsync -avz --progress .env ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:~/staging/ + + - name: Upload Migrations + run: | + rsync -avz --delete --progress .sqlc/migrations/ ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:~/staging/migrations/ + + - name: Boot Postgres and Run Migrations + uses: appleboy/ssh-action@v1.0.3 + env: + DB_NAME: ${{ secrets.DB_NAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_PORT: ${{ secrets.DB_PORT }} + DB_SSLMODE: ${{ secrets.DB_SSLMODE }} + DB_USER: ${{ secrets.DB_USER }} + APP_NAME: ${{ secrets.APP_NAME }} + with: + host: ${{ secrets.VPS_IP }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_KEY }} + envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME + script: | + cd $HOME/staging + + POSTGRES_DATA_DIR="$HOME/staging/postgres" + mkdir -p $POSTGRES_DATA_DIR + POSTGRES_CONTAINER="$APP_NAME-postgres" + + if ! docker ps --filter "name=$POSTGRES_CONTAINER" --format '{{.Names}}' | grep -q "^$POSTGRES_CONTAINER"; then + echo "PostgreSQL container not found. Starting new container..." + docker stop $POSTGRES_CONTAINER || true + docker rm $POSTGRES_CONTAINER || true + docker run -d \ + --name "$POSTGRES_CONTAINER" \ + -p "$DB_PORT:5432" \ + -v "$POSTGRES_DATA_DIR:/var/lib/postgresql/data" \ + --env POSTGRES_USER="${{ secrets.DB_USER }}" \ + --env POSTGRES_PASSWORD="${{ secrets.DB_PASSWORD }}" \ + --env POSTGRES_DB="${{ secrets.DB_NAME }}" \ + postgres:16 + + # Wait for PostgreSQL to be ready + echo "Waiting for PostgreSQL to be ready..." + RETRY_COUNT=0 + MAX_RETRIES=10 + while ! docker exec "$POSTGRES_CONTAINER" pg_isready -q; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "Error: PostgreSQL failed to start after $MAX_RETRIES attempts" + exit 1 + fi + echo "Waiting for PostgreSQL to be ready..." + sleep 1 + done + echo "Postgres is ready!" + else + echo "PostgreSQL container is already running" + fi + + goose fix -dir $HOME/staging/migrations + goose -dir $HOME/staging/migrations postgres \ + "postgres://$DB_USER:$DB_PASSWORD@localhost:$DB_PORT/$DB_NAME?sslmode=$DB_SSLMODE" up + + - name: Build Docker Image + run: | + docker build -t ${{ secrets.APP_NAME }}:latest . + docker save -o image.tar ${{ secrets.APP_NAME }}:latest + + - name: Upload Docker Image + run: | + rsync -avz image.tar ${{ secrets.VPS_USER }}@${{ secrets.VPS_IP }}:~/staging/ + + - name: Deploy Go App on VPS + uses: appleboy/ssh-action@v1.0.3 + env: + DB_NAME: ${{ secrets.DB_NAME }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_PORT: ${{ secrets.DB_PORT }} + DB_SSLMODE: ${{ secrets.DB_SSLMODE }} + DB_USER: ${{ secrets.DB_USER }} + APP_NAME: ${{ secrets.APP_NAME }} + with: + host: ${{ secrets.VPS_IP }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_KEY }} + envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME + script: | + cd $HOME/staging + + echo "Stopping and removing existing container if present..." + docker stop $APP_NAME || true + docker rm $APP_NAME || true + + echo "Loading pre-built docker image..." + docker load -i image.tar + + echo "Starting new application container..." + docker run -d \ + --name $APP_NAME \ + --env-file .env \ + --network=host --add-host=host.docker.internal:host-gateway \ + $APP_NAME:latest + + echo "Done: Preview Deployment" + + - name: Post Deployment Clean Up on VPS + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_IP }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_KEY }} + script: | + rm -rf $HOME/staging/migrations + rm -f $HOME/staging/image.tar + rm -f $HOME/staging/.env From c59900ede8653e03c63a1e35e6a3cf584175cf93 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:54:05 -0500 Subject: [PATCH 13/21] use APP_ENV as part of container name --- .github/workflows/deploy-staging-backend.yml | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-staging-backend.yml b/.github/workflows/deploy-staging-backend.yml index 765e09eb..b8ec130d 100644 --- a/.github/workflows/deploy-staging-backend.yml +++ b/.github/workflows/deploy-staging-backend.yml @@ -79,17 +79,18 @@ jobs: DB_SSLMODE: ${{ secrets.DB_SSLMODE }} DB_USER: ${{ secrets.DB_USER }} APP_NAME: ${{ secrets.APP_NAME }} + APP_ENV: ${{ secrets.APP_ENV }} with: host: ${{ secrets.VPS_IP }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_KEY }} - envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME + envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME,APP_ENV script: | cd $HOME/staging POSTGRES_DATA_DIR="$HOME/staging/postgres" mkdir -p $POSTGRES_DATA_DIR - POSTGRES_CONTAINER="$APP_NAME-postgres" + POSTGRES_CONTAINER="$APP_NAME-$APP_ENV-postgres" if ! docker ps --filter "name=$POSTGRES_CONTAINER" --format '{{.Names}}' | grep -q "^$POSTGRES_CONTAINER"; then echo "PostgreSQL container not found. Starting new container..." @@ -128,8 +129,8 @@ jobs: - name: Build Docker Image run: | - docker build -t ${{ secrets.APP_NAME }}:latest . - docker save -o image.tar ${{ secrets.APP_NAME }}:latest + docker build -t ${{ secrets.APP_NAME }}-${{ secrets.APP_ENV }}:latest . + docker save -o image.tar ${{ secrets.APP_NAME }}-${{ secrets.APP_ENV }}:latest - name: Upload Docker Image run: | @@ -144,27 +145,29 @@ jobs: DB_SSLMODE: ${{ secrets.DB_SSLMODE }} DB_USER: ${{ secrets.DB_USER }} APP_NAME: ${{ secrets.APP_NAME }} + APP_ENV: ${{ secrets.APP_ENV }} with: host: ${{ secrets.VPS_IP }} username: ${{ secrets.VPS_USER }} key: ${{ secrets.VPS_KEY }} - envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME + envs: DB_NAME,DB_PASSWORD,DB_PORT,DB_SSLMODE,DB_USER,APP_NAME,APP_ENV script: | cd $HOME/staging echo "Stopping and removing existing container if present..." - docker stop $APP_NAME || true - docker rm $APP_NAME || true + CONTAINER="$APP_NAME-$APP_ENV" + docker stop $CONTAINER || true + docker rm $CONTAINER || true echo "Loading pre-built docker image..." docker load -i image.tar echo "Starting new application container..." docker run -d \ - --name $APP_NAME \ + --name $CONTAINER \ --env-file .env \ --network=host --add-host=host.docker.internal:host-gateway \ - $APP_NAME:latest + "$APP_NAME-$APP_ENV:latest" echo "Done: Preview Deployment" From 1c4b016ef8e416fd234980eae381c216ed84da29 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:01:22 -0500 Subject: [PATCH 14/21] update environment name for backend --- .github/workflows/deploy-staging-backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-staging-backend.yml b/.github/workflows/deploy-staging-backend.yml index b8ec130d..e61c7392 100644 --- a/.github/workflows/deploy-staging-backend.yml +++ b/.github/workflows/deploy-staging-backend.yml @@ -8,7 +8,7 @@ on: jobs: deploy: runs-on: ubuntu-latest - environment: staging + environment: Staging defaults: run: shell: bash From 48ea1c079b8de4de8b639db8815a5cd26e3e2fc3 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:52:15 -0500 Subject: [PATCH 15/21] running frontend staging deployment action --- ...ontend.yml => deploy-staging-frontend.yml} | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) rename .github/workflows/{deploy-preview-frontend.yml => deploy-staging-frontend.yml} (70%) diff --git a/.github/workflows/deploy-preview-frontend.yml b/.github/workflows/deploy-staging-frontend.yml similarity index 70% rename from .github/workflows/deploy-preview-frontend.yml rename to .github/workflows/deploy-staging-frontend.yml index 0380ebfd..025a4545 100644 --- a/.github/workflows/deploy-preview-frontend.yml +++ b/.github/workflows/deploy-staging-frontend.yml @@ -9,6 +9,7 @@ on: jobs: build-and-deploy-frontend: runs-on: ubuntu-latest + environment: Staging defaults: run: shell: bash @@ -37,15 +38,20 @@ jobs: restore-keys: | ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: cd frontend && pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile + - name: Build environment + run: | + cat << EOF > .env + VITE_API_URL=${{ secrets.BACKEND_URL }}/api/v1 + EOF - name: Build - run: cd frontend && pnpm build + run: pnpm build - name: Deploy to server uses: appleboy/scp-action@master with: - host: ${{ secrets.PREVIEW_SERVER_IP }} - username: ${{ secrets.PREVIEW_USER }} - key: ${{ secrets.PREVIEW_SERVER_SSH_KEY }} - source: "frontend/dist/" - target: "${{ secrets.TARGET_DIR }}" - strip_components: 1 + host: ${{ secrets.VPS_IP }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_KEY }} + source: "frontend/dist/*" + target: "${{ secrets.FE_DIR }}" + strip_components: 2 From 941a60aebd798cc6eb80e0adf6f6a490bc434bbd Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Mon, 9 Dec 2024 00:11:17 -0500 Subject: [PATCH 16/21] update last echo backend deployment --- .github/workflows/deploy-staging-backend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-staging-backend.yml b/.github/workflows/deploy-staging-backend.yml index e61c7392..e6e6fa83 100644 --- a/.github/workflows/deploy-staging-backend.yml +++ b/.github/workflows/deploy-staging-backend.yml @@ -169,7 +169,7 @@ jobs: --network=host --add-host=host.docker.internal:host-gateway \ "$APP_NAME-$APP_ENV:latest" - echo "Done: Preview Deployment" + echo "Done: Staging Deployment" - name: Post Deployment Clean Up on VPS uses: appleboy/ssh-action@v1.0.3 From 4630c87de419c674c7850100fb0e26ef91e3a536 Mon Sep 17 00:00:00 2001 From: jc <46619361+juancwu@users.noreply.github.com> Date: Mon, 9 Dec 2024 00:11:43 -0500 Subject: [PATCH 17/21] remove old deployment script --- .github/scripts/deploy-preview-backend.sh | 74 ----------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/scripts/deploy-preview-backend.sh diff --git a/.github/scripts/deploy-preview-backend.sh b/.github/scripts/deploy-preview-backend.sh deleted file mode 100644 index 9b9d52a2..00000000 --- a/.github/scripts/deploy-preview-backend.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/bin/bash - -# immediately exit if any command returns a non-zero exit status. -set -e - -# -# Preview Deployment Script -# Handles environment setup, database initialization, and application deployment -# - -echo "Deploying new preview" - -# Initialize configuration files -echo "Get .env from Konbini" -echo "$MICONFIG" > .miconfig.yaml -echo "$PK" > private.pem - -# Environment setup -mi bento order - -# Export all environment variables automatically -set -a -source .env -set +a - -# PostgreSQL setup -echo "Setting up preview PostgreSQL container" -POSTGRES_DATA_DIR="$HOME/postgres" -mkdir -p $POSTGRES_DATA_DIR -POSTGRES_CONTAINER="$APP_NAME-postgres" - -# Check and start PostgreSQL container if needed -if ! docker ps | grep $POSTGRES_CONTAINER; then - echo "PostgreSQL container not found. Starting new container..." - docker run -d \ - --name $POSTGRES_CONTAINER \ - -v $POSTGRES_DATA_DIR:/var/lib/postgresql/data \ - -p $DB_PORT:5432 \ - --env-file .env \ - postgres:16 - - # Wait for PostgreSQL to be ready - echo "Waiting for PostgreSQL to be ready..." - timeout 90s bash -c "until docker exec $POSTGRES_CONTAINER pg_isready ; do sleep 1 ; done" \ - && echo "Postgres is ready!" -else - echo "PostgreSQL container is already running" -fi - -# Database migrations -echo "Fix migrations from timestampd to sequential" -goose fix -dir .sqlc/migrations -echo "Run migrations" -goose -dir .sqlc/migrations postgres \ - "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=$DB_SSLMODE" up - -# Application deployment -echo "Building application Docker image..." -docker build -t $APP_NAME:latest . - -# Container management -echo "Stopping and removing existing container if present..." -docker stop $APP_NAME || true -docker rm $APP_NAME || true - -echo "Starting new application container..." -docker run -d \ - --name $APP_NAME \ - --env-file .env \ - --network=host --add-host=host.docker.internal:host-gateway \ - -v ~/dist:/root/static/dist \ - $APP_NAME:latest - -echo "Done: Preview Deployment" From ecb232cd3fb2574f9430ce2c4cb8f1fb2e56e532 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:41:33 -0500 Subject: [PATCH 18/21] remove duplicate UserResponse struct --- backend/internal/server/auth.go | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/backend/internal/server/auth.go b/backend/internal/server/auth.go index e25cf867..0380bbfc 100644 --- a/backend/internal/server/auth.go +++ b/backend/internal/server/auth.go @@ -18,23 +18,13 @@ import ( ) type SignupResponse struct { - AccessToken string `json:"access_token"` - User UserResponse `json:"user"` + AccessToken string `json:"access_token"` + User User `json:"user"` } type SigninResponse struct { - AccessToken string `json:"access_token"` - User UserResponse `json:"user"` -} - -type UserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - FirstName *string `json:"first_name"` - LastName *string `json:"last_name"` - Role db.UserRole `json:"role"` - WalletAddress *string `json:"wallet_address,omitempty"` - EmailVerified bool `json:"email_verified"` + AccessToken string `json:"access_token"` + User User `json:"user"` } func (s *Server) setupAuthRoutes() { @@ -127,7 +117,7 @@ func (s *Server) handleSignup(c echo.Context) error { return c.JSON(http.StatusCreated, SignupResponse{ AccessToken: accessToken, - User: UserResponse{ + User: User{ ID: user.ID, Email: user.Email, FirstName: user.FirstName, @@ -180,7 +170,7 @@ func (s *Server) handleSignin(c echo.Context) error { return c.JSON(http.StatusOK, SigninResponse{ AccessToken: accessToken, - User: UserResponse{ + User: User{ ID: user.ID, Email: user.Email, FirstName: user.FirstName, From b3cf1eaae0f14cfd205f60bc566355b5b7acc413 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:47:01 -0500 Subject: [PATCH 19/21] remove redundant GetUserTokenSalt call --- backend/internal/server/auth.go | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/backend/internal/server/auth.go b/backend/internal/server/auth.go index 0380bbfc..8700d41e 100644 --- a/backend/internal/server/auth.go +++ b/backend/internal/server/auth.go @@ -65,13 +65,7 @@ func (s *Server) handleSignup(c echo.Context) error { return echo.NewHTTPError(http.StatusConflict, "email already exists") } - // Get user's token salt that was generated during creation - salt, err := s.queries.GetUserTokenSalt(ctx, user.ID) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user's token salt") - } - - accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, salt) + accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, user.TokenSalt) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate tokens") } @@ -145,13 +139,7 @@ func (s *Server) handleSignin(c echo.Context) error { return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") } - // Get user's token salt - salt, err := s.queries.GetUserTokenSalt(ctx, user.ID) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user's token salt") - } - - accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, salt) + accessToken, refreshToken, err := jwt.GenerateWithSalt(user.ID, user.Role, user.TokenSalt) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to generate tokens") } From 22f21ba59ae42f9fac82caf0081366e54cc3a341 Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:49:55 -0500 Subject: [PATCH 20/21] timeout context for async email verification --- backend/internal/server/auth.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/internal/server/auth.go b/backend/internal/server/auth.go index 8700d41e..979001a9 100644 --- a/backend/internal/server/auth.go +++ b/backend/internal/server/auth.go @@ -84,7 +84,10 @@ func (s *Server) handleSignup(c echo.Context) error { // Send verification email asynchronously go func() { - token, err := s.queries.CreateVerifyEmailToken(context.Background(), db.CreateVerifyEmailTokenParams{ + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + token, err := s.queries.CreateVerifyEmailToken(ctx, db.CreateVerifyEmailTokenParams{ Email: user.Email, ExpiresAt: time.Now().Add(30 * time.Minute), }) @@ -101,7 +104,7 @@ func (s *Server) handleSignup(c echo.Context) error { } // Send verification email - if err := service.SendVerficationEmail(context.Background(), user.Email, tokenStr); err != nil { + if err := service.SendVerficationEmail(ctx, user.Email, tokenStr); err != nil { log.Error().Err(err).Msg("Failed to send verification email") return } From 1514cdfea1806491e6a6846f7bf494b200eafa1f Mon Sep 17 00:00:00 2001 From: AmirAgassi <33383085+AmirAgassi@users.noreply.github.com> Date: Mon, 9 Dec 2024 14:12:24 -0500 Subject: [PATCH 21/21] move seed data from migrations to seeds directory --- .../20241221000001_update_seed_accounts.sql | 72 ---- .../20241221000002_seed_demo_companies.sql | 165 -------- backend/.sqlc/seeds/development_seed.sql | 365 ++++++++++++++++++ backend/Makefile | 6 +- 4 files changed, 370 insertions(+), 238 deletions(-) delete mode 100644 backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql delete mode 100644 backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql create mode 100644 backend/.sqlc/seeds/development_seed.sql diff --git a/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql b/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql deleted file mode 100644 index bb7b5802..00000000 --- a/backend/.sqlc/migrations/20241221000001_update_seed_accounts.sql +++ /dev/null @@ -1,72 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- clean up any existing seed accounts -DELETE FROM users WHERE email IN ('admin@spur.com', 'startup@test.com', 'investor@test.com'); - --- create admin user -INSERT INTO users ( - email, - password_hash, - first_name, - last_name, - role, - email_verified, - token_salt -) VALUES ( - 'admin@spur.com', - -- hash for 'admin123' - '$2a$10$jltnaECAYSCQozp5UNZi7OZQlyuTR3sJFj5Hr1nLEVmI9uSAxDKnq', - 'Admin', - 'User', - 'admin', - true, - gen_random_bytes(32) -); - --- create startup owner -INSERT INTO users ( - email, - password_hash, - first_name, - last_name, - role, - email_verified, - token_salt -) VALUES ( - 'startup@test.com', - -- hash for 'startup123' - '$2a$10$Cu72xg8m59GjDHKiFzK7pO8rLYjFL7XsPD6YezNkyZw8ItZBSnvfy', - 'Startup', - 'Owner', - 'startup_owner', - true, - gen_random_bytes(32) -); - --- create investor -INSERT INTO users ( - email, - password_hash, - first_name, - last_name, - role, - email_verified, - token_salt -) VALUES ( - 'investor@test.com', - -- hash for 'investor123' - '$2a$10$/7Mq7D4hlh0zisjOryL.KeeWSUU30tL5mJdYLAjcqeOodSPrbB.hK', - 'Test', - 'Investor', - 'investor', - true, - gen_random_bytes(32) -); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DELETE FROM users WHERE email IN ('admin@spur.com', 'startup@test.com', 'investor@test.com'); --- +goose StatementEnd \ No newline at end of file diff --git a/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql b/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql deleted file mode 100644 index de950a0e..00000000 --- a/backend/.sqlc/migrations/20241221000002_seed_demo_companies.sql +++ /dev/null @@ -1,165 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- get the startup owner's user id -WITH startup_user AS ( - SELECT id FROM users WHERE email = 'startup@test.com' LIMIT 1 -) --- create demo companies -INSERT INTO companies ( - id, - owner_user_id, - name, - description, - is_verified, - created_at, - updated_at -) -SELECT - gen_random_uuid(), - startup_user.id, - name, - description, - is_verified, - created_at, - updated_at -FROM startup_user, (VALUES - ( - 'TechVision AI', - 'An AI company focusing on computer vision solutions for autonomous vehicles', - true, - NOW() - INTERVAL '30 days', - NOW() - INTERVAL '2 days' - ), - ( - 'GreenEnergy Solutions', - 'Developing innovative solar panel technology for residential use', - true, - NOW() - INTERVAL '60 days', - NOW() - INTERVAL '5 days' - ), - ( - 'HealthTech Pro', - 'Healthcare technology focusing on remote patient monitoring', - false, - NOW() - INTERVAL '1 day', - NOW() - INTERVAL '1 day' - ), - ( - 'EduLearn Platform', - 'Online education platform with AI-powered personalized learning', - true, - NOW() - INTERVAL '90 days', - NOW() - INTERVAL '10 days' - ), - ( - 'FinTech Solutions', - 'Blockchain-based payment solutions for cross-border transactions', - false, - NOW() - INTERVAL '2 days', - NOW() - INTERVAL '2 days' - ) -) AS t(name, description, is_verified, created_at, updated_at); - --- Add some company financials -WITH companies_to_update AS ( - SELECT id, name FROM companies - WHERE name IN ('TechVision AI', 'GreenEnergy Solutions', 'EduLearn Platform') -) -INSERT INTO company_financials ( - company_id, - financial_year, - revenue, - expenses, - profit, - sales, - amount_raised, - arr, - grants_received -) -SELECT - id, - 2023, - 1000000.00, -- revenue - 800000.00, -- expenses - 200000.00, -- profit - 1200000.00, -- sales - 500000.00, -- amount raised - 960000.00, -- arr - 50000.00 -- grants -FROM companies_to_update; - --- Add some employees -WITH companies_to_update AS ( - SELECT id, name FROM companies - WHERE name IN ('TechVision AI', 'GreenEnergy Solutions') -) -INSERT INTO employees ( - company_id, - name, - email, - role, - bio -) -SELECT - c.id, - e.name, - e.email, - e.role, - e.bio -FROM companies_to_update c -CROSS JOIN (VALUES - ( - 'John Smith', - 'john@techvision.ai', - 'CTO', - 'Experienced AI researcher with 10+ years in computer vision' - ), - ( - 'Sarah Johnson', - 'sarah@techvision.ai', - 'Lead Engineer', - 'Senior software engineer specializing in deep learning' - ), - ( - 'Michael Green', - 'michael@greenenergy.com', - 'CEO', - 'Serial entrepreneur with background in renewable energy' - ), - ( - 'Lisa Chen', - 'lisa@greenenergy.com', - 'Head of R&D', - 'PhD in Material Science with focus on solar technology' - ) -) AS e(name, email, role, bio) -WHERE - (c.name = 'TechVision AI' AND e.email LIKE '%techvision%') OR - (c.name = 'GreenEnergy Solutions' AND e.email LIKE '%greenenergy%'); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin - --- Delete seeded employees -DELETE FROM employees -WHERE email IN ( - 'john@techvision.ai', - 'sarah@techvision.ai', - 'michael@greenenergy.com', - 'lisa@greenenergy.com' -); - --- Delete seeded financials and companies -DELETE FROM companies -WHERE name IN ( - 'TechVision AI', - 'GreenEnergy Solutions', - 'HealthTech Pro', - 'EduLearn Platform', - 'FinTech Solutions' -); - --- +goose StatementEnd \ No newline at end of file diff --git a/backend/.sqlc/seeds/development_seed.sql b/backend/.sqlc/seeds/development_seed.sql new file mode 100644 index 00000000..38068e63 --- /dev/null +++ b/backend/.sqlc/seeds/development_seed.sql @@ -0,0 +1,365 @@ +-- development seed data + +-- clean up any existing seed accounts +DELETE FROM users WHERE email IN ('admin@spur.com', 'startup@test.com', 'investor@test.com'); + +-- create admin user +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'admin@spur.com', + -- hash for 'admin123' + '$2a$10$jltnaECAYSCQozp5UNZi7OZQlyuTR3sJFj5Hr1nLEVmI9uSAxDKnq', + 'Admin', + 'User', + 'admin', + true, + gen_random_bytes(32) +); + +-- create startup owner +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'startup@test.com', + -- hash for 'startup123' + '$2a$10$Cu72xg8m59GjDHKiFzK7pO8rLYjFL7XsPD6YezNkyZw8ItZBSnvfy', + 'Startup', + 'Owner', + 'startup_owner', + true, + gen_random_bytes(32) +); + +-- create investor +INSERT INTO users ( + email, + password_hash, + first_name, + last_name, + role, + email_verified, + token_salt +) VALUES ( + 'investor@test.com', + -- hash for 'investor123' + '$2a$10$/7Mq7D4hlh0zisjOryL.KeeWSUU30tL5mJdYLAjcqeOodSPrbB.hK', + 'Test', + 'Investor', + 'investor', + true, + gen_random_bytes(32) +); + +-- clean up existing demo data +DELETE FROM employees WHERE email IN ( + 'john@techvision.ai', + 'sarah@techvision.ai', + 'michael@greenenergy.com', + 'lisa@greenenergy.com' +); +DELETE FROM companies WHERE name IN ( + 'TechVision AI', + 'GreenEnergy Solutions', + 'HealthTech Pro', + 'EduLearn Platform', + 'FinTech Solutions' +); + +-- get the startup owner's user id and create demo companies +WITH startup_user AS ( + SELECT id FROM users WHERE email = 'startup@test.com' LIMIT 1 +) +-- create demo companies +INSERT INTO companies ( + id, + owner_user_id, + name, + description, + is_verified, + created_at, + updated_at +) +SELECT + gen_random_uuid(), + startup_user.id, + name, + description, + is_verified, + created_at, + updated_at +FROM startup_user, (VALUES + ( + 'TechVision AI', + 'An AI company focusing on computer vision solutions for autonomous vehicles', + true, + NOW() - INTERVAL '30 days', + NOW() - INTERVAL '2 days' + ), + ( + 'GreenEnergy Solutions', + 'Developing innovative solar panel technology for residential use', + true, + NOW() - INTERVAL '60 days', + NOW() - INTERVAL '5 days' + ), + ( + 'HealthTech Pro', + 'Healthcare technology focusing on remote patient monitoring', + false, + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '1 day' + ), + ( + 'EduLearn Platform', + 'Online education platform with AI-powered personalized learning', + true, + NOW() - INTERVAL '90 days', + NOW() - INTERVAL '10 days' + ), + ( + 'FinTech Solutions', + 'Blockchain-based payment solutions for cross-border transactions', + false, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '2 days' + ) +) AS t(name, description, is_verified, created_at, updated_at); + +-- add company financials +WITH companies_to_update AS ( + SELECT id, name FROM companies + WHERE name IN ('TechVision AI', 'GreenEnergy Solutions', 'EduLearn Platform') +) +INSERT INTO company_financials ( + company_id, + financial_year, + revenue, + expenses, + profit, + sales, + amount_raised, + arr, + grants_received +) +SELECT + id, + 2023, + 1000000.00, -- revenue + 800000.00, -- expenses + 200000.00, -- profit + 1200000.00, -- sales + 500000.00, -- amount raised + 960000.00, -- arr + 50000.00 -- grants +FROM companies_to_update; + +-- add employees +WITH companies_to_update AS ( + SELECT id, name FROM companies + WHERE name IN ('TechVision AI', 'GreenEnergy Solutions') +) +INSERT INTO employees ( + company_id, + name, + email, + role, + bio +) +SELECT + c.id, + e.name, + e.email, + e.role, + e.bio +FROM companies_to_update c +CROSS JOIN (VALUES + ( + 'John Smith', + 'john@techvision.ai', + 'CTO', + 'Experienced AI researcher with 10+ years in computer vision' + ), + ( + 'Sarah Johnson', + 'sarah@techvision.ai', + 'Lead Engineer', + 'Senior software engineer specializing in deep learning' + ), + ( + 'Michael Green', + 'michael@greenenergy.com', + 'CEO', + 'Serial entrepreneur with background in renewable energy' + ), + ( + 'Lisa Chen', + 'lisa@greenenergy.com', + 'Head of R&D', + 'PhD in Material Science with focus on solar technology' + ) +) AS e(name, email, role, bio) +WHERE + (c.name = 'TechVision AI' AND e.email LIKE '%techvision%') OR + (c.name = 'GreenEnergy Solutions' AND e.email LIKE '%greenenergy%'); + +-- clean up existing projects +DELETE FROM project_links; +DELETE FROM project_comments; +DELETE FROM project_files; +DELETE FROM projects; + +-- add demo projects +WITH company_data AS ( + SELECT id, name FROM companies +) +INSERT INTO projects ( + company_id, + title, + description, + status, + created_at, + updated_at +) +SELECT + c.id, + p.title, + p.description, + p.status, + NOW() - (p.age || ' days')::INTERVAL, + NOW() - (p.last_update || ' days')::INTERVAL +FROM company_data c +CROSS JOIN (VALUES + ( + 'TechVision AI', + 'Autonomous Parking System', + 'AI-powered system for automated parallel and perpendicular parking', + 'in_progress', + 45, + 2 + ), + ( + 'TechVision AI', + 'Traffic Pattern Analysis', + 'Real-time traffic analysis using computer vision', + 'completed', + 90, + 30 + ), + ( + 'GreenEnergy Solutions', + 'Solar Panel Efficiency Optimizer', + 'AI-driven system to maximize solar panel energy collection', + 'in_progress', + 30, + 5 + ), + ( + 'EduLearn Platform', + 'Adaptive Learning Algorithm', + 'Machine learning system for personalized education paths', + 'in_review', + 15, + 1 + ) +) AS p(company_name, title, description, status, age, last_update) +WHERE c.name = p.company_name; + +-- add project links +WITH project_data AS ( + SELECT p.id, p.title, c.name as company_name + FROM projects p + JOIN companies c ON p.company_id = c.id +) +INSERT INTO project_links ( + project_id, + link_type, + url +) +SELECT + pd.id, + l.link_type, + l.url +FROM project_data pd +CROSS JOIN (VALUES + ( + 'TechVision AI', + 'Autonomous Parking System', + 'github', + 'https://github.com/techvision/parking-ai' + ), + ( + 'TechVision AI', + 'Autonomous Parking System', + 'demo', + 'https://demo.techvision.ai/parking' + ), + ( + 'GreenEnergy Solutions', + 'Solar Panel Efficiency Optimizer', + 'documentation', + 'https://docs.greenenergy.com/solar-optimizer' + ) +) AS l(company_name, project_title, link_type, url) +WHERE pd.company_name = l.company_name AND pd.title = l.project_title; + +-- add project comments +WITH project_data AS ( + SELECT p.id as project_id, + u.id as user_id, + p.title as project_title, + c.name as company_name + FROM projects p + JOIN companies c ON p.company_id = c.id + CROSS JOIN users u +) +INSERT INTO project_comments ( + project_id, + user_id, + comment, + created_at +) +SELECT + pd.project_id, + pd.user_id, + pc.comment, + NOW() - (pc.days_ago || ' days')::INTERVAL +FROM project_data pd +CROSS JOIN (VALUES + ( + 'TechVision AI', + 'Autonomous Parking System', + 'investor@test.com', + 'This looks great!', + 5 + ), + ( + 'TechVision AI', + 'Autonomous Parking System', + 'startup@test.com', + 'YOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO', + 4 + ), + ( + 'GreenEnergy Solutions', + 'Solar Panel Efficiency Optimizer', + 'investor@test.com', + 'This sucks. :(', + 3 + ) +) AS pc(company_name, project_title, user_email, comment, days_ago) +WHERE pd.company_name = pc.company_name + AND pd.project_title = pc.project_title + AND EXISTS (SELECT 1 FROM users u WHERE u.id = pd.user_id AND u.email = pc.user_email); \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index a39c8b99..0c87b7bf 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -8,7 +8,7 @@ TEST_DB_HOST_PORT ?= 5432 TEST_DB_URL ?= postgres://$(TEST_DB_USER):$(TEST_DB_PASSWORD)@localhost:$(TEST_DB_HOST_PORT)/$(TEST_DB_NAME)?sslmode=disable POSTGRESQL_VERSION ?= 16 -.PHONY: query +.PHONY: query seed dev: @VERSION=dev APP_ENV=development air @@ -21,6 +21,10 @@ migration: sql: @sqlc generate +seed: + @echo "Running development seed data..." + @cat .sqlc/seeds/development_seed.sql | docker exec -i nokap-postgres psql -U postgres -d postgres + setup: @go install github.com/air-verse/air@v1.61.1 && \ go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.27.0 && \