diff --git a/.gitignore b/.gitignore
index a96ebf7..007c8cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -113,4 +113,5 @@ android/local.properties
docker-data/
.obsidian
.excalidraw
-.gocache
\ No newline at end of file
+.gocache
+apps/web/.jest-cache/
diff --git a/apps/server/cmd/cors_test.go b/apps/server/cmd/cors_test.go
new file mode 100644
index 0000000..33098f4
--- /dev/null
+++ b/apps/server/cmd/cors_test.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/labstack/echo/v5"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCORSMiddlewareAllowsWebClientHeaders(t *testing.T) {
+ e := echo.New()
+ e.Use(corsMiddleware("http://localhost:3000"))
+ e.POST("/api/v1/auth/login", func(c *echo.Context) error {
+ return c.NoContent(http.StatusOK)
+ })
+
+ req := httptest.NewRequest(http.MethodOptions, "/api/v1/auth/login", nil)
+ req.Header.Set("Origin", "http://localhost:3000")
+ req.Header.Set("Access-Control-Request-Method", http.MethodPost)
+ req.Header.Set("Access-Control-Request-Headers", "content-type,x-requested-with")
+ rec := httptest.NewRecorder()
+
+ e.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusNoContent, rec.Code)
+ require.Equal(t, "http://localhost:3000", rec.Header().Get("Access-Control-Allow-Origin"))
+ require.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
+
+ allowedHeaders := strings.ToLower(rec.Header().Get("Access-Control-Allow-Headers"))
+ require.Contains(t, allowedHeaders, "content-type")
+ require.Contains(t, allowedHeaders, "x-requested-with")
+}
diff --git a/apps/server/cmd/main.go b/apps/server/cmd/main.go
index cf73277..7e2d697 100644
--- a/apps/server/cmd/main.go
+++ b/apps/server/cmd/main.go
@@ -5,7 +5,7 @@ import (
"time"
"github.com/coderz-space/coderz.space/internal/common/logger"
- "github.com/coderz-space/coderz.space/internal/common/middleware"
+ appMiddleware "github.com/coderz-space/coderz.space/internal/common/middleware"
"github.com/coderz-space/coderz.space/internal/common/middleware/timeout"
config "github.com/coderz-space/coderz.space/internal/config"
"github.com/coderz-space/coderz.space/internal/container"
@@ -47,8 +47,33 @@ import (
// @tag.name Bootcamp Enrollments
// @tag.description Bootcamp enrollment management endpoints
-func main() {
+func corsConfig(frontendOrigin string) echoMiddleware.CORSConfig {
+ return echoMiddleware.CORSConfig{
+ AllowOrigins: []string{frontendOrigin},
+ AllowMethods: []string{
+ http.MethodGet,
+ http.MethodPost,
+ http.MethodPut,
+ http.MethodPatch,
+ http.MethodDelete,
+ },
+ AllowHeaders: []string{
+ "Origin",
+ "Content-Type",
+ "Accept",
+ "Authorization",
+ "X-Requested-With",
+ },
+ ExposeHeaders: []string{"Content-Length", "Content-Type", "X-Request-Id"},
+ AllowCredentials: true,
+ }
+}
+func corsMiddleware(frontendOrigin string) echo.MiddlewareFunc {
+ return echoMiddleware.CORSWithConfig(corsConfig(frontendOrigin))
+}
+
+func main() {
cfg := config.LoadConfig()
logger.Initialize(cfg)
defer func() {
@@ -64,15 +89,9 @@ func main() {
e := echo.New()
// middleware
- e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
- AllowOrigins: []string{cfg.FrontendOrigin},
- AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete},
- AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
- ExposeHeaders: []string{"Content-Length", "Content-Type", "X-Request-Id"},
- AllowCredentials: true,
- }))
- e.Use(middleware.ZapLogger())
- e.Use(middleware.Recovery())
+ e.Use(corsMiddleware(cfg.FrontendOrigin))
+ e.Use(appMiddleware.ZapLogger())
+ e.Use(appMiddleware.Recovery())
e.Use(timeout.TimeoutMiddleware(30 * time.Second)) // 30 second timeout to prevent resource exhaustion
// swagger docs
diff --git a/apps/server/internal/modules/auth/service.go b/apps/server/internal/modules/auth/service.go
index fc986f5..8cce451 100644
--- a/apps/server/internal/modules/auth/service.go
+++ b/apps/server/internal/modules/auth/service.go
@@ -6,21 +6,25 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "strings"
"time"
+ "github.com/coderz-space/coderz.space/internal/common/email"
"github.com/coderz-space/coderz.space/internal/common/logger"
"github.com/coderz-space/coderz.space/internal/common/utils"
- "github.com/coderz-space/coderz.space/internal/common/email"
"github.com/coderz-space/coderz.space/internal/config"
db "github.com/coderz-space/coderz.space/internal/db/sqlc"
+ "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
+const errEmailAlreadyExists = "EMAIL_ALREADY_EXISTS"
+
type Service struct {
- queries *db.Queries
- config *config.Config
+ queries *db.Queries
+ config *config.Config
emailService email.Service
}
@@ -46,12 +50,27 @@ func (s *Service) Signup(ctx context.Context, req SignupRequest) (*AuthResponseD
Role: db.UserRoleUser,
})
if err != nil {
- return nil, err
+ return nil, normalizeSignupError(err)
}
return s.generateAuthData(ctx, &user)
}
+func normalizeSignupError(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "users_email_key" {
+ return errors.New(errEmailAlreadyExists)
+ }
+
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "users_email_key") ||
+ strings.Contains(errMsg, "duplicate key value violates unique constraint") {
+ return errors.New(errEmailAlreadyExists)
+ }
+
+ return err
+}
+
func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponseData, error) {
user, err := s.queries.GetUserByEmail(ctx, pgtype.Text{String: req.Email, Valid: true})
if err != nil {
diff --git a/apps/server/internal/modules/auth/signup_handler_test.go b/apps/server/internal/modules/auth/signup_handler_test.go
index 23d73cc..3bb3b9f 100644
--- a/apps/server/internal/modules/auth/signup_handler_test.go
+++ b/apps/server/internal/modules/auth/signup_handler_test.go
@@ -108,8 +108,8 @@ func TestHandler_Signup(t *testing.T) {
checkResponse: func(t *testing.T, rec *httptest.ResponseRecorder) {
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp)
- if resp["message"] != "duplicate key value violates unique constraint" {
- t.Errorf("Expected database error message, got %v", resp["message"])
+ if resp["message"] != "EMAIL_ALREADY_EXISTS" {
+ t.Errorf("Expected EMAIL_ALREADY_EXISTS, got %v", resp["message"])
}
},
},
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index a7b2064..6b87d6d 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -12,6 +12,7 @@
# testing
/coverage
+/.jest-cache/
# next.js
/.next/
@@ -40,4 +41,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
-.kiro
\ No newline at end of file
+.kiro
diff --git a/apps/web/__tests__/example.test.tsx b/apps/web/__tests__/example.test.tsx
index 730f292..7f5d0f3 100644
--- a/apps/web/__tests__/example.test.tsx
+++ b/apps/web/__tests__/example.test.tsx
@@ -1,6 +1,3 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-
describe('Example test', () => {
it('should pass', () => {
expect(1 + 1).toBe(2);
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index e4b8221..ea57fea 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import Script from "next/script";
import "./globals.css";
import ThemeToggle from "@/components/ThemeToggle";
@@ -15,13 +16,10 @@ export default function RootLayout({
}>) {
return (
-
- {/* Blocking script: sets dark class before first paint to avoid flash */}
-
-
+
{children}
diff --git a/apps/web/components/HeroSection.tsx b/apps/web/components/HeroSection.tsx
index 2b53727..83ff067 100644
--- a/apps/web/components/HeroSection.tsx
+++ b/apps/web/components/HeroSection.tsx
@@ -2,19 +2,41 @@ import { HeroSectionProps } from "@/types";
export default function HeroSection({ onGetStarted }: HeroSectionProps) {
return (
-
-
- Welcome to Algo Buddy
-
-
- A collaborative platform where developers master DSA and tech stacks through peer learning, problem-solving, and real progress tracking.
-
-
- Get Started
-
+
+
+
+
+
+ Peer learning for serious DSA practice
+
+
+ Welcome to Algo Buddy
+
+
+ A collaborative platform where developers master DSA and tech stacks through peer learning, problem-solving, and real progress tracking.
+
+
+ Get Started
+
+
+
+
+ {[
+ ["01", "Choose your role", "Mentor and mentee paths stay focused from the first click."],
+ ["02", "Practice with structure", "Assignments and sheets keep daily progress visible."],
+ ["03", "Track real growth", "Profiles and leaderboards make outcomes easy to scan."],
+ ].map(([step, title, text]) => (
+
+
{step}
+
{title}
+
{text}
+
+ ))}
+
+
);
}
diff --git a/apps/web/components/LandingPage.tsx b/apps/web/components/LandingPage.tsx
index 26d3321..7fa787e 100644
--- a/apps/web/components/LandingPage.tsx
+++ b/apps/web/components/LandingPage.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useCallback, useState } from "react";
import type { Role } from "@/types";
import HeroSection from "@/components/HeroSection";
import RoleCard from "@/components/RoleCard";
@@ -14,33 +14,38 @@ export default function LandingPage() {
const [view, setView] = useState("none");
const [activeRole, setActiveRole] = useState(null);
- const handleSelectRole = async (role: Role) => {
+ const closeDialog = useCallback(() => setView("none"), []);
+ const showRoleSelect = useCallback(() => setView("roleSelect"), []);
+ const showSignUp = useCallback(() => setView("signUp"), []);
+ const showLogin = useCallback(() => setView("login"), []);
+
+ const handleSelectRole = useCallback(async (role: Role) => {
await selectRole(role);
setActiveRole(role);
setView("login");
- };
+ }, []);
return (
-
- setView("roleSelect")} />
+
+
{view === "roleSelect" && (
- setView("none")} />
+
)}
{view === "login" && activeRole && (
setView("none")}
- onSignUp={() => setView("signUp")}
+ onClose={closeDialog}
+ onSignUp={showSignUp}
/>
)}
{view === "signUp" && activeRole && (
setView("none")}
- onBackToLogin={() => setView("login")}
+ onClose={closeDialog}
+ onBackToLogin={showLogin}
/>
)}
diff --git a/apps/web/components/MenteeLoginCard.tsx b/apps/web/components/MenteeLoginCard.tsx
index 0d8fcbe..d507300 100644
--- a/apps/web/components/MenteeLoginCard.tsx
+++ b/apps/web/components/MenteeLoginCard.tsx
@@ -1,8 +1,9 @@
"use client";
-import { useState } from "react";
+import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { loginMenteeByEmail } from "@/services/auth";
+import Modal from "@/components/Modal";
interface MenteeLoginCardProps {
role: "mentor" | "mentee";
@@ -25,7 +26,7 @@ function EyeIcon({ visible }: { visible: boolean }) {
}
const inputClass =
- "w-full rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100";
+ "w-full rounded-lg border border-purple-200 bg-white px-4 py-2 text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-800 dark:bg-gray-800 dark:text-gray-100";
export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLoginCardProps) {
const [email, setEmail] = useState("");
@@ -35,7 +36,8 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin
const [loading, setLoading] = useState(false);
const router = useRouter();
- const handleLogin = async () => {
+ const handleLogin = async (event?: FormEvent) => {
+ event?.preventDefault();
setError("");
if (!email.trim() || !password) {
setError("Please fill in all fields.");
@@ -69,10 +71,10 @@ export default function MenteeLoginCard({ role, onClose, onSignUp }: MenteeLogin
};
return (
-
-
event.stopPropagation()}
+
+
+
{role === "mentee" ? (
{
event.stopPropagation();
onSignUp();
}}
- className="mt-4 text-sm text-purple-600 hover:underline dark:text-purple-400"
+ className="mt-4 block w-full text-center text-sm text-purple-100 hover:underline"
>
New user? Sign Up
) : null}
-
+
);
}
diff --git a/apps/web/components/MenteeSignUpCard.tsx b/apps/web/components/MenteeSignUpCard.tsx
index cf1d370..8c873f7 100644
--- a/apps/web/components/MenteeSignUpCard.tsx
+++ b/apps/web/components/MenteeSignUpCard.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState } from "react";
+import { useState, type FormEvent } from "react";
import { registerMentee } from "@/services/auth";
+import Modal from "@/components/Modal";
interface MenteeSignUpCardProps {
role: "mentor" | "mentee";
@@ -24,7 +25,7 @@ function EyeIcon({ visible }: { visible: boolean }) {
}
const inputClass =
- "w-full rounded-lg border border-purple-300 bg-white px-4 py-2 text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-700 dark:bg-gray-800 dark:text-gray-100";
+ "w-full rounded-lg border border-purple-200 bg-white px-4 py-2 text-gray-900 transition focus:outline-none focus:ring-2 focus:ring-purple-500 dark:border-purple-800 dark:bg-gray-800 dark:text-gray-100";
export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: MenteeSignUpCardProps) {
const [firstName, setFirstName] = useState("");
@@ -39,7 +40,8 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
- const handleSignUp = async () => {
+ const handleSignUp = async (event?: FormEvent) => {
+ event?.preventDefault();
setError("");
if (role !== "mentee") {
setError("Mentor accounts are provisioned separately.");
@@ -77,27 +79,29 @@ export default function MenteeSignUpCard({ role, onClose, onBackToLogin }: Mente
};
return (
-
-
event.stopPropagation()}
+
+
+
);
}
diff --git a/apps/web/components/Modal.tsx b/apps/web/components/Modal.tsx
index 35e3f34..7f8e8a7 100644
--- a/apps/web/components/Modal.tsx
+++ b/apps/web/components/Modal.tsx
@@ -21,7 +21,9 @@ export default function Modal({ onClose, children, className = "" }: ModalProps)
return (
{
if (innerRef.current && !innerRef.current.contains(e.target as Node)) {
onClose();
diff --git a/apps/web/components/RoleCard.tsx b/apps/web/components/RoleCard.tsx
index 25bacfb..85d864c 100644
--- a/apps/web/components/RoleCard.tsx
+++ b/apps/web/components/RoleCard.tsx
@@ -1,31 +1,24 @@
import { RoleCardProps } from "@/types";
+import Modal from "@/components/Modal";
export default function RoleCard({ onSelectRole, onClose }: RoleCardProps) {
return (
-
-
e.stopPropagation()}
+
+
+ Log in as
+
+ onSelectRole("mentor")}
+ className="w-full rounded-lg bg-purple-600 py-2 font-semibold text-white transition hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
-
- Log in as
-
- onSelectRole("mentor")}
- className="bg-purple-600 hover:bg-purple-700 text-white w-full py-2 rounded-lg"
- >
- Mentor
-
- onSelectRole("mentee")}
- className="bg-purple-600 hover:bg-purple-700 text-white w-full py-2 rounded-lg"
- >
- Mentee
-
-
-
+ Mentor
+
+
onSelectRole("mentee")}
+ className="w-full rounded-lg bg-purple-600 py-2 font-semibold text-white transition hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
+ >
+ Mentee
+
+
);
}
diff --git a/apps/web/components/ThemeToggle.tsx b/apps/web/components/ThemeToggle.tsx
index ee977cb..edbc0fc 100644
--- a/apps/web/components/ThemeToggle.tsx
+++ b/apps/web/components/ThemeToggle.tsx
@@ -29,9 +29,10 @@ export default function ThemeToggle() {
return (
{isDark ? (
diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js
new file mode 100644
index 0000000..235bcf8
--- /dev/null
+++ b/apps/web/jest.config.js
@@ -0,0 +1,22 @@
+/** @type {import('jest').Config} */
+const config = {
+ testEnvironment: "jsdom",
+ transform: {
+ "^.+\\.(ts|tsx)$": ["ts-jest", { tsconfig: { jsx: "react-jsx" } }],
+ },
+ moduleNameMapper: {
+ "^@/(.*)$": "/$1",
+ },
+ setupFilesAfterEnv: ["@testing-library/jest-dom"],
+ testPathIgnorePatterns: ["/.next/", "/node_modules/"],
+ modulePathIgnorePatterns: ["/.next/"],
+ maxWorkers: 1,
+ collectCoverageFrom: [
+ "components/**/*.{ts,tsx}",
+ "services/**/*.{ts,tsx}",
+ "app/**/*.{ts,tsx}",
+ "!**/*.d.ts",
+ ],
+};
+
+module.exports = config;
diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts
deleted file mode 100644
index 8142117..0000000
--- a/apps/web/jest.config.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Config } from 'jest';
-
-const config: Config = {
- testEnvironment: 'jsdom',
- transform: {
- '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: { jsx: 'react-jsx' } }],
- },
- moduleNameMapper: {
- '^@/(.*)$': '/$1',
- },
- modulePathIgnorePatterns: ['/.next/'],
- setupFilesAfterEnv: ['@testing-library/jest-dom'],
- testPathIgnorePatterns: ['/.next/', '/node_modules/'],
- modulePathIgnorePatterns: ['/.next/'],
- maxWorkers: 1,
- collectCoverageFrom: [
- 'components/**/*.{ts,tsx}',
- 'services/**/*.{ts,tsx}',
- 'app/**/*.{ts,tsx}',
- '!**/*.d.ts',
- ],
-};
-
-export default config;
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 68a6c64..8aa5a18 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
+ turbopack: {
+ root: __dirname,
+ },
};
export default nextConfig;
diff --git a/apps/web/services/api.ts b/apps/web/services/api.ts
index 5d07d47..084acda 100644
--- a/apps/web/services/api.ts
+++ b/apps/web/services/api.ts
@@ -114,15 +114,15 @@ export async function request(config: AxiosRequestConfig): Promise {
export const api = {
get: (url: string, config?: AxiosRequestConfig) =>
request({ ...config, method: "GET", url }),
- post: (url: string, data?: B, config?: AxiosRequestConfig) =>
+ post: (url: string, data?: B, config?: AxiosRequestConfig) =>
request({ ...config, method: "POST", url, data }),
- put: (url: string, data?: B, config?: AxiosRequestConfig) =>
+ put: (url: string, data?: B, config?: AxiosRequestConfig) =>
request({ ...config, method: "PUT", url, data }),
- patch: (url: string, data?: B, config?: AxiosRequestConfig) =>
+ patch: (url: string, data?: B, config?: AxiosRequestConfig) =>
request({ ...config, method: "PATCH", url, data }),
delete: (url: string, config?: AxiosRequestConfig) =>
request({ ...config, method: "DELETE", url }),
- rawPost: (url: string, data?: B, config?: AxiosRequestConfig) =>
+ rawPost: (url: string, data?: B, config?: AxiosRequestConfig) =>
requestRaw({ ...config, method: "POST", url, data }),
rawGet: (url: string, config?: AxiosRequestConfig) =>
requestRaw({ ...config, method: "GET", url }),