diff --git a/.cursor/cursor.md b/.cursor/cursor.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/wallet-auth-flow.md b/docs/wallet-auth-flow.md new file mode 100644 index 0000000..261f5bb --- /dev/null +++ b/docs/wallet-auth-flow.md @@ -0,0 +1,133 @@ +# Wallet-Based Authentication Flow + +## Overview + +VolunChain now uses wallet-based authentication instead of traditional email/password authentication. This provides a more secure and decentralized approach to user identification. + +## Registration Flow + +### Endpoint: `POST /auth/register` + +**Request Body:** + +```json +{ + "name": "John Doe", + "email": "john@example.com", + "walletAddress": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "profileType": "user", // or "project" + "lastName": "Doe", // optional, for users + "category": "environmental" // required for projects +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "User registered successfully", + "userId": "uuid-here" +} +``` + +### Profile Types + +1. **User Profile** (`profileType: "user"`) + + - Requires: name, email, walletAddress + - Optional: lastName + - Auto-verified upon registration + +2. **Project/Organization Profile** (`profileType: "project"`) + - Requires: name, email, walletAddress, category + - Auto-verified upon registration + +## Login Flow + +### Endpoint: `POST /auth/login` + +**Request Body:** + +```json +{ + "walletAddress": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" +} +``` + +**Response:** + +```json +{ + "success": true, + "message": "Login successful", + "token": "jwt-token-here", + "user": { + "id": "uuid-here", + "name": "John Doe", + "email": "john@example.com", + "wallet": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "profileType": "user" + } +} +``` + +## Protected Routes + +### Authentication Middleware + +All protected routes require a valid JWT token in the Authorization header: + +``` +Authorization: Bearer +``` + +### Profile-Specific Routes + +- `/auth/user-only` - Only accessible by user profiles +- `/auth/organization-only` - Only accessible by organization profiles +- `/auth/profile` - Accessible by both profile types + +## Security Features + +1. **Wallet Address Validation**: Ensures wallet addresses are valid Stellar addresses (56 characters) +2. **Unique Wallet Constraint**: Each wallet address can only be registered once +3. **JWT Tokens**: Secure token-based authentication with 24-hour expiration +4. **Auto-Verification**: All wallet-based registrations are automatically verified + +## Error Handling + +### Common Error Responses + +**Wallet Address Not Found:** + +```json +{ + "success": false, + "message": "Wallet address not found" +} +``` + +**Wallet Address Already Registered:** + +```json +{ + "success": false, + "message": "Wallet address already registered" +} +``` + +**Invalid Token:** + +```json +{ + "success": false, + "message": "Invalid or expired token" +} +``` + +## Migration Notes + +- Password fields have been removed from User and Organization models +- All existing authentication logic has been updated to use wallet-based auth +- Email verification is no longer required for wallet-based registrations diff --git a/openapi.yaml b/openapi.yaml index 78827c3..1e00ff9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4,18 +4,33 @@ info: description: | API for managing projects in VolunChain with versioning support. - ## API Versioning - This API uses URL path versioning with the following structure: - - `/api/v1/` - Current stable API version - - `/api/v2/` - Reserved for future expansion + ## Authentication + VolunChain uses wallet-based authentication with Stellar addresses. + - No passwords required + - JWT tokens for session management + - Support for user and organization profiles - All endpoints are namespaced under their respective version paths. + ## API Structure + This API uses a simple structure without versioning: + - `/api/` - Main API endpoints + - `/api/docs` - API documentation + + All endpoints are organized by functionality. version: 1.0.0 + contact: + name: VolunChain Support + email: support@volunchain.org servers: - - url: http://localhost:3000/api/v1 - description: Local development server (v1) - url: http://localhost:3000/api - description: API root (version info) + description: Local development server + - url: https://api.volunchain.org + description: Production server + +tags: + - name: Authentication + description: Wallet-based authentication endpoints + - name: API Info + description: API version and health information paths: # API Root endpoint (available at /api/) @@ -69,40 +84,214 @@ paths: type: string example: "/api/docs" - # V1 API Endpoints (all under /api/v1/) + # API Endpoints (all under /api/) + /auth/register: + post: + tags: + - Authentication + summary: "Register new user or organization" + description: | + Register a new profile in the system. Can be a user or organization. + + **Important notes:** + - Wallet address must be a valid Stellar address (56 characters) + - Each wallet address can only be registered once + - Registrations are automatically verified + - For organizations, category field is required + operationId: "register" + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + examples: + user_registration: + summary: User registration + value: + name: "Juan Pérez" + email: "juan.perez@email.com" + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + profileType: "user" + lastName: "Pérez" + organization_registration: + summary: Organization registration + value: + name: "Fundación Ambiental Verde" + email: "contacto@fundacionverde.org" + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + profileType: "project" + category: "environmental" + responses: + '201': + description: "Registration successful" + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterResponse' + '400': + description: "Validation error or duplicate data" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: "Internal server error" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /auth/login: post: - summary: "User login" + tags: + - Authentication + summary: "Login with wallet" + description: | + Authenticate a user or organization using their wallet address. + + **Important notes:** + - Only requires wallet address + - Returns JWT token valid for 24 hours + - Includes profile information in response operationId: "login" requestBody: - description: "User wallet address" + required: true content: application/json: schema: - type: object - required: - - walletAddress - properties: - walletAddress: - type: string - description: "User's wallet address" + $ref: '#/components/schemas/LoginRequest' + example: + walletAddress: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" responses: '200': - description: "Authentication successful" + description: "Login successful" + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: "Wallet address not found" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: "Internal server error" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/profile: + get: + tags: + - Authentication + summary: "Get authenticated user profile" + description: | + Get the profile information of the authenticated user. + + **Requirements:** + - Valid JWT token in Authorization header + - Accessible for both user and organization profiles + operationId: "getProfile" + security: + - BearerAuth: [] + responses: + '200': + description: "Profile information retrieved successfully" + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileResponse' + '401': + description: "Invalid or expired token" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/user-only: + get: + tags: + - Authentication + summary: "User-only route" + description: | + Endpoint that can only be accessed by user profiles. + + **Requirements:** + - Valid JWT token + - User profile type + operationId: "userOnly" + security: + - BearerAuth: [] + responses: + '200': + description: "Access allowed for users" content: application/json: schema: type: object properties: - token: + success: + type: boolean + example: true + message: type: string - description: "JWT authentication token" + example: "User-only route accessed successfully" '401': - description: "Unauthorized" + description: "Invalid token" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: "Access denied - requires user profile" content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' + + /auth/organization-only: + get: + tags: + - Authentication + summary: "Organization-only route" + description: | + Endpoint that can only be accessed by organization profiles. + + **Requirements:** + - Valid JWT token + - Organization profile type + operationId: "organizationOnly" + security: + - BearerAuth: [] + responses: + '200': + description: "Access allowed for organizations" + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Organization-only route accessed successfully" + '401': + description: "Invalid token" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: "Access denied - requires organization profile" + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /auth/protected: get: @@ -122,7 +311,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' /organizations/{organizationId}/projects: get: @@ -149,7 +338,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' /projects: post: @@ -173,7 +362,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' /projects/{id}: get: @@ -198,7 +387,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' /volunteers/{volunteerId}/users: get: @@ -225,7 +414,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/ErrorResponse' components: schemas: @@ -254,11 +443,7 @@ components: type: string format: date - Error: - type: object - properties: - error: - type: string + Project: type: object @@ -301,3 +486,169 @@ components: type: string name: type: string + + # Authentication Schemas + RegisterRequest: + type: object + required: + - name + - email + - walletAddress + - profileType + properties: + name: + type: string + minLength: 2 + maxLength: 100 + description: "Name of the user or organization" + example: "Juan Pérez" + email: + type: string + format: email + description: "Unique email address" + example: "juan.perez@email.com" + walletAddress: + type: string + minLength: 56 + maxLength: 56 + description: "Stellar wallet address (56 characters)" + example: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + profileType: + type: string + enum: [user, project] + description: "Type of profile to create" + example: "user" + lastName: + type: string + maxLength: 100 + description: "Last name (only for user profiles)" + example: "Pérez" + category: + type: string + maxLength: 100 + description: "Organization category (required for project profiles)" + example: "environmental" + + RegisterResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "User registered successfully" + userId: + type: string + format: uuid + description: "User ID (only for user profiles)" + example: "123e4567-e89b-12d3-a456-426614174000" + organizationId: + type: string + format: uuid + description: "Organization ID (only for project profiles)" + example: "123e4567-e89b-12d3-a456-426614174001" + + LoginRequest: + type: object + required: + - walletAddress + properties: + walletAddress: + type: string + minLength: 56 + maxLength: 56 + description: "Stellar wallet address for authentication" + example: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + + LoginResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Login successful" + token: + type: string + description: "JWT token for authentication" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + user: + $ref: '#/components/schemas/UserProfile' + + UserProfile: + type: object + properties: + id: + type: string + format: uuid + description: "Unique user/organization ID" + example: "123e4567-e89b-12d3-a456-426614174000" + name: + type: string + description: "User/organization name" + example: "Juan Pérez" + email: + type: string + format: email + description: "User/organization email" + example: "juan.perez@email.com" + wallet: + type: string + description: "Stellar wallet address" + example: "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + profileType: + type: string + enum: [user, organization] + description: "Profile type" + example: "user" + + ProfileResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Profile accessed successfully" + user: + $ref: '#/components/schemas/UserProfile' + + ErrorResponse: + type: object + properties: + success: + type: boolean + example: false + message: + type: string + description: "Error description" + example: "Wallet address not found" + errors: + type: array + items: + type: object + properties: + field: + type: string + description: "Field that caused the error" + example: "walletAddress" + message: + type: string + description: "Specific error message" + example: "Wallet address must be 56 characters long" + description: "List of validation errors (only for 400 errors)" + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + JWT token obtained from login. + + **Format:** `Bearer ` + + **Example:** `Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...` diff --git a/prisma/migrations/20250823062033_remove_password_fields/migration.sql b/prisma/migrations/20250823062033_remove_password_fields/migration.sql new file mode 100644 index 0000000..17637d7 --- /dev/null +++ b/prisma/migrations/20250823062033_remove_password_fields/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `password` on the `Organization` table. All the data in the column will be lost. + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Organization" DROP COLUMN "password"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "password", +ALTER COLUMN "isVerified" SET DEFAULT true; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25f7d17..9493085 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,29 +4,28 @@ generator client { } datasource db { - provider = "postgresql" // Specifies PostgreSQL as the database provider - url = env("DATABASE_URL") // Loads the database connection URL from environment variables - directUrl = env("DIRECT_URL") // For direct connection to the database + provider = "postgresql" // Specifies PostgreSQL as the database provider + url = env("DATABASE_URL") // Loads the database connection URL from environment variables + directUrl = env("DIRECT_URL") // For direct connection to the database relationMode = "prisma" } // User model represents a system user model User { - id String @id @default(uuid()) // Unique identifier - createdAt DateTime @default(now()) // Timestamp for record creation - updatedAt DateTime @updatedAt // Timestamp for record update - name String - lastName String? - email String @unique // Unique email constraint - password String - wallet String @unique // Unique wallet identifier - isVerified Boolean @default(false) - verificationToken String? - verificationTokenExpires DateTime? - nfts NFT[] @relation("UserNFTs") // One-to-many relationship with NFTs - userVolunteers UserVolunteer[] @relation("UserToVolunteer") // Relation to UserVolunteer - sentMessages Message[] @relation("SentMessages") - receivedMessages Message[] @relation("ReceivedMessages") + id String @id @default(uuid()) // Unique identifier + createdAt DateTime @default(now()) // Timestamp for record creation + updatedAt DateTime @updatedAt // Timestamp for record update + name String + lastName String? + email String @unique // Unique email constraint + wallet String @unique // Unique wallet identifier + isVerified Boolean @default(true) // Auto-verified for wallet-based auth + verificationToken String? + verificationTokenExpires DateTime? + nfts NFT[] @relation("UserNFTs") // One-to-many relationship with NFTs + userVolunteers UserVolunteer[] @relation("UserToVolunteer") // Relation to UserVolunteer + sentMessages Message[] @relation("SentMessages") + receivedMessages Message[] @relation("ReceivedMessages") } // Organization model represents an entity like a company or group @@ -36,7 +35,6 @@ model Organization { updatedAt DateTime @updatedAt name String email String @unique - password String category String wallet String @unique nfts NFT[] @relation("OrganizationNFTs") // One-to-many relationship with NFTs @@ -94,33 +92,33 @@ model TestItem { // UserVolunteer model represents the relationship between Users and Volunteers model UserVolunteer { - userId String // Foreign key for User - volunteerId String // Foreign key for Volunteer + userId String // Foreign key for User + volunteerId String // Foreign key for Volunteer joinedAt DateTime @default(now()) hoursContributed Float @default(0) // Track hours contributed by the volunteer user User @relation(fields: [userId], references: [id], name: "UserToVolunteer") volunteer Volunteer @relation(fields: [volunteerId], references: [id], name: "VolunteerToUser") @@id([userId, volunteerId]) // Use composite ID from metrics branch - @@index([userId]) // Keep indexes from main - @@index([volunteerId]) // Keep indexes from main + @@index([userId]) // Keep indexes from main + @@index([volunteerId]) // Keep indexes from main } // Volunteer model represents a volunteer position within a project model Volunteer { - id String @id @default(uuid()) - name String - description String - requirements String - incentive String? - maxVolunteers Int @default(10) - projectId String - project Project @relation(fields: [projectId], references: [id]) - userVolunteers UserVolunteer[] @relation("VolunteerToUser") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - certificate Certificate? - messages Message[] + id String @id @default(uuid()) + name String + description String + requirements String + incentive String? + maxVolunteers Int @default(10) + projectId String + project Project @relation(fields: [projectId], references: [id]) + userVolunteers UserVolunteer[] @relation("VolunteerToUser") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + certificate Certificate? + messages Message[] @@index([projectId]) } @@ -160,30 +158,30 @@ model Photo { } model Certificate { - id String @id @default(cuid()) - volunteerId String @unique - volunteer Volunteer @relation(fields: [volunteerId], references: [id]) - s3Key String @default("https://onlydust-app-images.s3.eu-west-1.amazonaws.com/8ee3b7d84fe0672850e4c81890361b73.png") - uniqueId String @unique + id String @id @default(cuid()) + volunteerId String @unique + volunteer Volunteer @relation(fields: [volunteerId], references: [id]) + s3Key String @default("https://onlydust-app-images.s3.eu-west-1.amazonaws.com/8ee3b7d84fe0672850e4c81890361b73.png") + uniqueId String @unique customMessage String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt downloads CertificateDownloadLog[] } model Message { - id String @id @default(uuid()) - content String - sentAt DateTime @default(now()) - readAt DateTime? - - senderId String - receiverId String - volunteerId String - - sender User @relation("SentMessages", fields: [senderId], references: [id]) - receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) - volunteer Volunteer @relation(fields: [volunteerId], references: [id]) + id String @id @default(uuid()) + content String + sentAt DateTime @default(now()) + readAt DateTime? + + senderId String + receiverId String + volunteerId String + + sender User @relation("SentMessages", fields: [senderId], references: [id]) + receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) + volunteer Volunteer @relation(fields: [volunteerId], references: [id]) @@index([senderId]) @@index([receiverId]) @@ -191,9 +189,9 @@ model Message { } model CertificateDownloadLog { - id String @id @default(cuid()) + id String @id @default(cuid()) certificateId String userId String - timestamp DateTime @default(now()) + timestamp DateTime @default(now()) certificate Certificate @relation(fields: [certificateId], references: [id]) } diff --git a/readme.md b/readme.md index ccdf2a7..cfe8f0b 100644 --- a/readme.md +++ b/readme.md @@ -236,19 +236,6 @@ npm run db:seed --- -## 🔌 Supabase Integration - -This project uses Supabase for external data access and future integrations. - -Update your `.env` file with: - -```bash -SUPABASE_URL=... -SUPABASE_ANON_KEY=... -``` - ---- - ## 📁 Module Overview ### Core Modules diff --git a/src/index.ts b/src/index.ts index e20d455..bfc7a6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,19 +9,10 @@ import { errorHandler } from "./middlewares/errorHandler"; import { dbPerformanceMiddleware } from "./middlewares/dbPerformanceMiddleware"; import { setupRateLimiting } from "./middleware/rateLimitMiddleware"; import { cronManager } from "./utils/cron"; -import apiRouter from "./routes"; + import { traceIdMiddleware } from "./middlewares/traceId.middleware"; import { requestLoggerMiddleware } from "./middlewares/requestLogger.middleware"; -import authRoutes from "./routes/authRoutes"; -import router from "./routes/nftRoutes"; -import userRoutes from "./routes/userRoutes"; -import metricsRoutes from "./modules/metrics/routes/metrics.routes"; -import certificateRoutes from "./routes/certificatesRoutes"; -import volunteerRoutes from "./routes/VolunteerRoutes"; -import projectRoutes from "./routes/ProjectRoutes"; -import organizationRoutes from "./routes/OrganizationRoutes"; -import messageRoutes from "./modules/messaging/routes/messaging.routes"; -import testRoutes from "./routes/testRoutes"; +import apiRouter from "./routes"; import { Logger } from "./utils/logger"; const globalLogger = new Logger("VolunChain"); @@ -145,31 +136,9 @@ app.get("/health", async (req, res) => { res.status(httpStatus).json(healthStatus); }); -// API Routes with versioning +// API Routes app.use("/api", apiRouter); -// Authentication routes -app.use("/auth", authRoutes); - -// NFT routes -app.use("/nft", router); - -// User routes -app.use("/users", userRoutes); - -// Metrics routes -app.use("/metrics", metricsRoutes); - -// Other routes -app.use("/certificate", certificateRoutes); -app.use("/projects", projectRoutes); -app.use("/volunteers", volunteerRoutes); -app.use("/organizations", organizationRoutes); -router.use("/messages", messageRoutes); - -// Test routes -app.use("/test", testRoutes); - // Initialize the database and start the server prisma .$connect() @@ -186,13 +155,10 @@ prisma globalLogger.info("Cron jobs initialized successfully!"); app.listen(PORT, () => { - globalLogger.info( - `Server is running on http://localhost:${PORT}`, - { - port: PORT, - environment: ENV, - } - ); + globalLogger.info(`Server is running on http://localhost:${PORT}`, { + port: PORT, + environment: ENV, + }); if (ENV === "development") { globalLogger.info( diff --git a/src/middleware/authMiddleware.ts b/src/middleware/authMiddleware.ts index 5a0384a..50beaf9 100644 --- a/src/middleware/authMiddleware.ts +++ b/src/middleware/authMiddleware.ts @@ -1,100 +1,70 @@ -import { Request, Response, NextFunction } from "express"; +import { Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; -import { PrismaUserRepository } from "../modules/user/repositories/PrismaUserRepository"; -import { - AuthenticatedRequest, - DecodedUser, - toAuthenticatedUser, -} from "../types/auth.types"; +import { AuthenticatedRequest, DecodedUser } from "../types/auth.types"; -const SECRET_KEY = process.env.JWT_SECRET || "defaultSecret"; -const userRepository = new PrismaUserRepository(); - -export const authMiddleware = async ( - req: Request, +export const authMiddleware = ( + req: AuthenticatedRequest, res: Response, next: NextFunction -): Promise => { - const token = req.headers.authorization?.split(" ")[1]; - - if (!token) { - res.status(401).json({ message: "No token provided" }); - return; - } - +): void => { try { - const decoded = jwt.verify(token, SECRET_KEY) as { - id: string; - role: string; - }; - const user = await userRepository.findById(`${decoded.id}`); + const token = req.headers.authorization?.replace("Bearer ", ""); - if (!user) { - res.status(401).json({ message: "User not found" }); - return; - } - - if (!user.isVerified) { - res.status(403).json({ - message: "Email not verified. Please verify your email to proceed.", + if (!token) { + res.status(401).json({ + success: false, + message: "Access token required", }); return; } - (req as AuthenticatedRequest).user = { - id: user.id, - email: user.email, - role: decoded.role, - isVerified: user.isVerified, - }; + const decoded = jwt.verify( + token, + process.env.JWT_SECRET || "your-secret-key" + ) as DecodedUser; + req.user = decoded; next(); - } catch (error) { - console.error("Error during authentication:", error); - res.status(401).json({ message: "Invalid token" }); + } catch { + res.status(401).json({ + success: false, + message: "Invalid or expired token", + }); } }; -export const requireVerifiedEmail = async ( - req: Request, +export const requireUserProfile = ( + req: AuthenticatedRequest, res: Response, next: NextFunction -): Promise => { - try { - const authenticatedReq = req as AuthenticatedRequest; - - if (!authenticatedReq.user) { - res - .status(401) - .json({ message: "Unauthorized - Authentication required" }); - return; - } - - const isVerified = await userRepository.isUserVerified( - authenticatedReq.user.id.toString() - ); - - if (!isVerified) { - res.status(403).json({ - message: "Forbidden - Email verification required", - verificationNeeded: true, - }); - return; - } +): void => { + if (req.user?.profileType !== "user") { + res.status(403).json({ + success: false, + message: "User profile required for this action", + }); + return; + } + next(); +}; - authenticatedReq.user.isVerified = true; - next(); - } catch (error) { - // Use basic console.error here to avoid circular dependencies - console.error("Error checking email verification status:", error); - res.status(500).json({ - message: "Internal server error", - ...(req.traceId && { traceId: req.traceId }), +export const requireOrganizationProfile = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void => { + if (req.user?.profileType !== "organization") { + res.status(403).json({ + success: false, + message: "Organization profile required for this action", }); + return; } + next(); }; export default { - requireVerifiedEmail, authMiddleware, + requireUserProfile, + requireOrganizationProfile, }; diff --git a/src/modules/auth/domain/interfaces/auth.interface.ts b/src/modules/auth/domain/interfaces/auth.interface.ts new file mode 100644 index 0000000..514c3ae --- /dev/null +++ b/src/modules/auth/domain/interfaces/auth.interface.ts @@ -0,0 +1,77 @@ +export interface IUser { + id: string; + name: string; + lastName: string | null; + email: string; + wallet: string; + isVerified: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface IOrganization { + id: string; + name: string; + email: string; + wallet: string; + category: string; + createdAt: Date; + updatedAt: Date; +} + +export interface IProfile { + name: string; + email: string; + wallet: string; + profileType: "user" | "organization"; + category?: string; +} + +export interface ILoginRequest { + walletAddress: string; +} + +export interface IRegisterRequest { + name: string; + email: string; + walletAddress: string; + profileType: "user" | "project"; + lastName?: string; + category?: string; +} + +export interface ILoginResponse { + success: boolean; + message: string; + token?: string; + user?: IProfile; +} + +export interface IRegisterResponse { + success: boolean; + message: string; + profile?: IProfile; +} + +export interface IJWTPayload { + userId: string; + email: string; + profileType: "user" | "organization"; + iat?: number; + exp?: number; +} + +export interface IAuthError { + success: false; + message: string; + code?: string; + details?: Record; +} + +export interface IAuthSuccess { + success: true; + message: string; + data?: T; +} + +export type AuthResult = IAuthSuccess | IAuthError; diff --git a/src/modules/auth/domain/services/jwt.service.ts b/src/modules/auth/domain/services/jwt.service.ts new file mode 100644 index 0000000..7c1fdd5 --- /dev/null +++ b/src/modules/auth/domain/services/jwt.service.ts @@ -0,0 +1,156 @@ +import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken"; +import { IJWTPayload } from "../interfaces/auth.interface"; + +export class JWTService { + private static readonly SECRET_KEY = + process.env.JWT_SECRET || + "volunchain-super-secret-key-change-in-production"; + private static readonly DEFAULT_EXPIRATION = "24h"; + private static readonly REFRESH_EXPIRATION = "7d"; + + /** + * Generates a JWT token for authentication + * @param payload - The payload to encode in the token + * @param expiresIn - Token expiration time (default: 24h) + * @returns JWT token string + */ + static generateToken( + payload: Omit, + expiresIn: string = this.DEFAULT_EXPIRATION + ): string { + try { + const options: SignOptions = { + expiresIn: expiresIn as jwt.SignOptions["expiresIn"], + issuer: "volunchain-api", + audience: "volunchain-users", + }; + + return jwt.sign(payload, this.SECRET_KEY, options); + } catch (error) { + throw new Error( + `Failed to generate JWT token: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * Generates a refresh token + * @param payload - The payload to encode in the token + * @returns Refresh token string + */ + static generateRefreshToken( + payload: Omit + ): string { + return this.generateToken(payload, this.REFRESH_EXPIRATION); + } + + /** + * Verifies and decodes a JWT token + * @param token - The JWT token to verify + * @returns Decoded payload + */ + static verifyToken(token: string): IJWTPayload { + try { + const options: VerifyOptions = { + issuer: "volunchain-api", + audience: "volunchain-users", + }; + + const decoded = jwt.verify( + token, + this.SECRET_KEY, + options + ) as IJWTPayload; + return decoded; + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new Error("Token has expired"); + } else if (error instanceof jwt.JsonWebTokenError) { + throw new Error("Invalid token"); + } else { + throw new Error( + `Token verification failed: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + } + + /** + * Decodes a JWT token without verification (for debugging purposes) + * @param token - The JWT token to decode + * @returns Decoded payload + */ + static decodeToken(token: string): IJWTPayload | null { + try { + return jwt.decode(token) as IJWTPayload; + } catch { + return null; + } + } + + /** + * Checks if a token is expired + * @param token - The JWT token to check + * @returns boolean indicating if token is expired + */ + static isTokenExpired(token: string): boolean { + try { + const decoded = jwt.decode(token) as IJWTPayload; + if (!decoded || !decoded.exp) { + return true; + } + + const currentTime = Math.floor(Date.now() / 1000); + return decoded.exp < currentTime; + } catch { + return true; + } + } + + /** + * Extracts token from Authorization header + * @param authHeader - Authorization header value + * @returns Token string or null + */ + static extractTokenFromHeader(authHeader: string | undefined): string | null { + if (!authHeader) { + return null; + } + + const parts = authHeader.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") { + return null; + } + + return parts[1]; + } + + /** + * Gets token expiration time in seconds + * @param token - The JWT token + * @returns Expiration time in seconds or null + */ + static getTokenExpiration(token: string): number | null { + try { + const decoded = jwt.decode(token) as IJWTPayload; + return decoded?.exp || null; + } catch { + return null; + } + } + + /** + * Gets time until token expires in seconds + * @param token - The JWT token + * @returns Time until expiration in seconds or null + */ + static getTimeUntilExpiration(token: string): number | null { + const expiration = this.getTokenExpiration(token); + if (!expiration) { + return null; + } + + const currentTime = Math.floor(Date.now() / 1000); + return Math.max(0, expiration - currentTime); + } +} diff --git a/src/modules/auth/domain/services/wallet-validation.service.ts b/src/modules/auth/domain/services/wallet-validation.service.ts new file mode 100644 index 0000000..cb6ff0f --- /dev/null +++ b/src/modules/auth/domain/services/wallet-validation.service.ts @@ -0,0 +1,97 @@ +import { Keypair } from "@stellar/stellar-sdk"; + +export class WalletValidationService { + /** + * Validates if a wallet address is a valid Stellar address + * @param walletAddress - The wallet address to validate + * @returns boolean indicating if the address is valid + */ + static isValidStellarAddress(walletAddress: string): boolean { + if (!walletAddress || typeof walletAddress !== "string") { + return false; + } + + // Stellar addresses must be exactly 56 characters long + if (walletAddress.length !== 56) { + return false; + } + + // Stellar addresses start with 'G' (for public keys) + if (!walletAddress.startsWith("G")) { + return false; + } + + try { + // Try to create a Keypair from the address to validate it + Keypair.fromPublicKey(walletAddress); + return true; + } catch { + return false; + } + } + + /** + * Validates wallet address format and returns detailed error information + * @param walletAddress - The wallet address to validate + * @returns Validation result with details + */ + static validateWalletAddress(walletAddress: string): { + isValid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (!walletAddress) { + errors.push("Wallet address is required"); + return { isValid: false, errors }; + } + + if (typeof walletAddress !== "string") { + errors.push("Wallet address must be a string"); + return { isValid: false, errors }; + } + + if (walletAddress.length !== 56) { + errors.push("Stellar wallet address must be exactly 56 characters long"); + } + + if (!walletAddress.startsWith("G")) { + errors.push("Stellar wallet address must start with 'G'"); + } + + if (errors.length === 0) { + try { + Keypair.fromPublicKey(walletAddress); + } catch { + errors.push("Invalid Stellar wallet address format"); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Normalizes a wallet address (trims whitespace, converts to uppercase) + * @param walletAddress - The wallet address to normalize + * @returns Normalized wallet address + */ + static normalizeWalletAddress(walletAddress: string): string { + return walletAddress.trim().toUpperCase(); + } + + /** + * Checks if two wallet addresses are the same (case-insensitive) + * @param address1 - First wallet address + * @param address2 - Second wallet address + * @returns boolean indicating if addresses are the same + */ + static areAddressesEqual(address1: string, address2: string): boolean { + return ( + this.normalizeWalletAddress(address1) === + this.normalizeWalletAddress(address2) + ); + } +} diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts index ec4080c..7a9b1fe 100644 --- a/src/modules/auth/dto/login.dto.ts +++ b/src/modules/auth/dto/login.dto.ts @@ -1,10 +1,12 @@ -import { IsString, IsEmail, IsNotEmpty } from "class-validator"; +import { IsString, MinLength, MaxLength } from "class-validator"; export class LoginDto { - @IsEmail({}, { message: "Please provide a valid email address" }) - email: string; - - @IsString({ message: "Password must be a string" }) - @IsNotEmpty({ message: "Password is required" }) - password: string; + @IsString({ message: "Wallet address must be a string" }) + @MinLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + @MaxLength(56, { + message: "Stellar wallet address must be 56 characters long", + }) + walletAddress: string; } diff --git a/src/modules/auth/dto/register.dto.ts b/src/modules/auth/dto/register.dto.ts index 0e7dbf2..6ed3598 100644 --- a/src/modules/auth/dto/register.dto.ts +++ b/src/modules/auth/dto/register.dto.ts @@ -3,9 +3,15 @@ import { IsEmail, MinLength, MaxLength, + IsEnum, IsOptional, } from "class-validator"; +export enum ProfileType { + USER = "user", + PROJECT = "project", +} + export class RegisterDto { @IsString({ message: "Name must be a string" }) @MinLength(2, { message: "Name must be at least 2 characters long" }) @@ -15,12 +21,6 @@ export class RegisterDto { @IsEmail({}, { message: "Please provide a valid email address" }) email: string; - @IsString({ message: "Password must be a string" }) - @MinLength(8, { message: "Password must be at least 8 characters long" }) - @MaxLength(128, { message: "Password cannot exceed 128 characters" }) - password: string; - - @IsOptional() @IsString({ message: "Wallet address must be a string" }) @MinLength(56, { message: "Stellar wallet address must be 56 characters long", @@ -28,5 +28,20 @@ export class RegisterDto { @MaxLength(56, { message: "Stellar wallet address must be 56 characters long", }) - walletAddress?: string; + walletAddress: string; + + @IsEnum(ProfileType, { + message: "Profile type must be either 'user' or 'project'", + }) + profileType: ProfileType; + + @IsOptional() + @IsString({ message: "Last name must be a string" }) + @MaxLength(100, { message: "Last name cannot exceed 100 characters" }) + lastName?: string; + + @IsOptional() + @IsString({ message: "Category must be a string" }) + @MaxLength(100, { message: "Category cannot exceed 100 characters" }) + category?: string; } diff --git a/src/modules/auth/infrastructure/repositories/auth.repository.ts b/src/modules/auth/infrastructure/repositories/auth.repository.ts new file mode 100644 index 0000000..600e0be --- /dev/null +++ b/src/modules/auth/infrastructure/repositories/auth.repository.ts @@ -0,0 +1,250 @@ +import { PrismaClient } from "@prisma/client"; +import { + IUser, + IOrganization, + IProfile, +} from "../../domain/interfaces/auth.interface"; +import { WalletValidationService } from "../../domain/services/wallet-validation.service"; + +export class AuthRepository { + private prisma: PrismaClient; + + constructor() { + this.prisma = new PrismaClient(); + } + + /** + * Finds a user by wallet address + * @param walletAddress - The wallet address to search for + * @returns User object or null + */ + async findUserByWallet(walletAddress: string): Promise { + try { + const normalizedAddress = + WalletValidationService.normalizeWalletAddress(walletAddress); + + const user = await this.prisma.user.findUnique({ + where: { wallet: normalizedAddress }, + }); + + return user; + } catch (error) { + console.error("Error finding user by wallet:", error); + return null; + } + } + + /** + * Finds an organization by wallet address + * @param walletAddress - The wallet address to search for + * @returns Organization object or null + */ + async findOrganizationByWallet( + walletAddress: string + ): Promise { + try { + const normalizedAddress = + WalletValidationService.normalizeWalletAddress(walletAddress); + + const organization = await this.prisma.organization.findUnique({ + where: { wallet: normalizedAddress }, + }); + + return organization; + } catch (error) { + console.error("Error finding organization by wallet:", error); + return null; + } + } + + /** + * Finds any profile (user or organization) by wallet address + * @param walletAddress - The wallet address to search for + * @returns Profile object or null + */ + async findProfileByWallet(walletAddress: string): Promise { + try { + const normalizedAddress = + WalletValidationService.normalizeWalletAddress(walletAddress); + + // Try to find user first + const user = await this.findUserByWallet(normalizedAddress); + if (user) { + return { + name: user.name, + email: user.email, + wallet: user.wallet, + profileType: "user" as const, + }; + } + + // Try to find organization + const organization = + await this.findOrganizationByWallet(normalizedAddress); + if (organization) { + return { + name: organization.name, + email: organization.email, + wallet: organization.wallet, + profileType: "organization" as const, + category: organization.category, + }; + } + + return null; + } catch (error) { + console.error("Error finding profile by wallet:", error); + return null; + } + } + + /** + * Checks if a wallet address is already registered + * @param walletAddress - The wallet address to check + * @returns boolean indicating if wallet is registered + */ + async isWalletRegistered(walletAddress: string): Promise { + try { + const normalizedAddress = + WalletValidationService.normalizeWalletAddress(walletAddress); + + const [user, organization] = await Promise.all([ + this.prisma.user.findUnique({ where: { wallet: normalizedAddress } }), + this.prisma.organization.findUnique({ + where: { wallet: normalizedAddress }, + }), + ]); + + return !!(user || organization); + } catch (error) { + console.error("Error checking wallet registration:", error); + return false; + } + } + + /** + * Checks if an email is already registered + * @param email - The email to check + * @returns boolean indicating if email is registered + */ + async isEmailRegistered(email: string): Promise { + try { + const normalizedEmail = email.toLowerCase().trim(); + + const [user, organization] = await Promise.all([ + this.prisma.user.findUnique({ where: { email: normalizedEmail } }), + this.prisma.organization.findUnique({ + where: { email: normalizedEmail }, + }), + ]); + + return !!(user || organization); + } catch (error) { + console.error("Error checking email registration:", error); + return false; + } + } + + /** + * Creates a new user + * @param userData - User data to create + * @returns Created user object + */ + async createUser(userData: { + name: string; + lastName?: string; + email: string; + wallet: string; + }): Promise { + try { + const normalizedWallet = WalletValidationService.normalizeWalletAddress( + userData.wallet + ); + const normalizedEmail = userData.email.toLowerCase().trim(); + + const user = await this.prisma.user.create({ + data: { + name: userData.name, + lastName: userData.lastName || "", + email: normalizedEmail, + wallet: normalizedWallet, + isVerified: true, // Auto-verified for wallet-based auth + }, + }); + + return user; + } catch (error) { + console.error("Error creating user:", error); + throw new Error( + `Failed to create user: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * Creates a new organization + * @param organizationData - Organization data to create + * @returns Created organization object + */ + async createOrganization(organizationData: { + name: string; + email: string; + wallet: string; + category: string; + }): Promise { + try { + const normalizedWallet = WalletValidationService.normalizeWalletAddress( + organizationData.wallet + ); + const normalizedEmail = organizationData.email.toLowerCase().trim(); + + const organization = await this.prisma.organization.create({ + data: { + name: organizationData.name, + email: normalizedEmail, + wallet: normalizedWallet, + category: organizationData.category, + }, + }); + + return organization; + } catch (error) { + console.error("Error creating organization:", error); + throw new Error( + `Failed to create organization: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * Updates user verification status + * @param userId - User ID to update + * @param isVerified - New verification status + * @returns Updated user object + */ + async updateUserVerification( + userId: string, + isVerified: boolean + ): Promise { + try { + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { isVerified }, + }); + + return user; + } catch (error) { + console.error("Error updating user verification:", error); + throw new Error( + `Failed to update user verification: ${error instanceof Error ? error.message : "Unknown error"}` + ); + } + } + + /** + * Disconnects from the database + */ + async disconnect(): Promise { + await this.prisma.$disconnect(); + } +} diff --git a/src/modules/auth/presentation/controllers/Auth.controller.ts b/src/modules/auth/presentation/controllers/Auth.controller.ts index 1ba7a6d..811c751 100644 --- a/src/modules/auth/presentation/controllers/Auth.controller.ts +++ b/src/modules/auth/presentation/controllers/Auth.controller.ts @@ -1,172 +1,415 @@ import { Request, Response } from "express"; +import { validateOr400 } from "../../../../shared/middleware/validation.middleware"; +import { LoginDto } from "../../dto/login.dto"; +import { RegisterDto } from "../../dto/register.dto"; +import { LoginUseCase } from "../../use-cases/login.usecase"; +import { RegisterUseCase } from "../../use-cases/register.usecase"; +import { JWTService } from "../../domain/services/jwt.service"; +import { WalletValidationService } from "../../domain/services/wallet-validation.service"; +import { IAuthError, IProfile } from "../../domain/interfaces/auth.interface"; -// imports for DTO validator -import { plainToInstance } from "class-transformer"; -import { validate } from "class-validator"; +export class AuthController { + private loginUseCase: LoginUseCase; + private registerUseCase: RegisterUseCase; -// Necessary DTOs -import { RegisterDto } from "../../dto/register.dto"; -import { LoginDto } from "../../dto/login.dto"; -import { ResendVerificationDTO } from "../../dto/resendVerificationDTO"; -import { - VerifyWalletDto, - ValidateWalletFormatDto, -} from "../../dto/wallet-validation.dto"; - -// Use cases -import { PrismaUserRepository } from "../../../user/repositories/PrismaUserRepository"; -import { SendVerificationEmailUseCase } from "../../use-cases/send-verification-email.usecase"; -import { ResendVerificationEmailUseCase } from "../../use-cases/resend-verification-email.usecase"; -import { VerifyEmailUseCase } from "../../use-cases/verify-email.usecase"; -import { ValidateWalletFormatUseCase } from "../../use-cases/wallet-format-validation.usecase"; -import { VerifyWalletUseCase } from "../../use-cases/verify-wallet.usecase"; - -const userRepository = new PrismaUserRepository(); -const sendVerificationEmailUseCase = new SendVerificationEmailUseCase( - userRepository -); -const resendVerificationEmailUseCase = new ResendVerificationEmailUseCase( - userRepository -); -const verifyEmailUseCase = new VerifyEmailUseCase(userRepository); -const validateWalletFormatUseCase = new ValidateWalletFormatUseCase(); -const verifyWalletUseCase = new VerifyWalletUseCase(); - -// DTO validator -async function validateOr400( - Cls: new () => T, - payload: unknown, - res: Response -): Promise { - const dto = plainToInstance(Cls, payload); - const errors = await validate(dto as object, { - whitelist: true, - forbidNonWhitelisted: true, - }); - - // dto not verified, throw a Bad Request - if (errors.length) { - res.status(400).json({ message: "Validation failed", errors }); - return; + constructor() { + this.loginUseCase = new LoginUseCase(); + this.registerUseCase = new RegisterUseCase(); } - return dto; -} + /** + * Register a new user or organization + */ + register = async (req: Request, res: Response): Promise => { + try { + const dto = await validateOr400(RegisterDto, req.body, res); + if (!dto) return; -const register = async (req: Request, res: Response) => { - const dto = await validateOr400(RegisterDto, req.body, res); - if (!dto) return; - - try { - // Send verification email to provided address - await sendVerificationEmailUseCase.execute({ email: dto.email }); - res.status(200).json({ message: "Verification email sent" }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to send verification email"; - const status = message === "User not found" ? 400 : 500; - res.status(status).json({ error: message }); - } -}; - -const login = async (req: Request, res: Response) => { - const dto = await validateOr400(LoginDto, req.body, res); - if (!dto) return; - - // TODO: Implement Wallet auth logic as a use case - res.status(501).json({ - message: "Login service temporarily disabled", - error: "Wallet auth logic not implemented yet", - }); -}; - -const resendVerificationEmail = async (req: Request, res: Response) => { - const dto = await validateOr400(ResendVerificationDTO, req.body, res); - if (!dto) return; - - try { - // Resends verification email to provided address - await resendVerificationEmailUseCase.execute({ email: dto.email }); - res.status(200).json({ message: "Verification email resent" }); - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Failed to resend verification email"; - const status = message === "User not found" ? 404 : 500; - res.status(status).json({ error: message }); - } -}; - -const verifyEmail = async (req: Request, res: Response) => { - const tokenParam = - typeof req.params.token === "string" ? req.params.token : undefined; - const tokenQuery = - typeof req.query.token === "string" - ? (req.query.token as string) - : undefined; - const token = tokenParam || tokenQuery; - - // if token is not given in the request - if (!token) { - res.status(400).json({ - success: false, - message: "Token in URL is required", - verified: false, - }); - return; - } + const result = await this.registerUseCase.execute({ + name: dto.name, + email: dto.email, + walletAddress: dto.walletAddress, + profileType: dto.profileType, + lastName: dto.lastName, + category: dto.category, + }); - try { - // Verifies email using use case - const result = await verifyEmailUseCase.execute({ token }); - const status = result.success ? 200 : 400; - res.status(status).json(result); - } catch { - res.status(400).json({ - success: false, - message: "Invalid or expired verification token", - verified: false, - }); - } -}; - -const verifyWallet = async (req: Request, res: Response) => { - const dto = await validateOr400(VerifyWalletDto, req.body, res); - if (!dto) return; - - try { - const result = await verifyWalletUseCase.execute(dto); - const status = result.verified ? 200 : 400; - res.status(status).json(result); - } catch (err) { - const message = - err instanceof Error ? err.message : "Wallet verification failed"; - res.status(500).json({ error: message }); + if (result.success && result.data) { + res.status(201).json(result.data); + } else { + const errorResult = result as IAuthError; + const statusCode = this.getStatusCodeForError(errorResult.code); + res.status(statusCode).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Registration controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during registration", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Login with wallet address + */ + login = async (req: Request, res: Response): Promise => { + try { + const dto = await validateOr400(LoginDto, req.body, res); + if (!dto) return; + + const result = await this.loginUseCase.execute({ + walletAddress: dto.walletAddress, + }); + + if (result.success && result.data) { + res.status(200).json(result.data); + } else { + const errorResult = result as IAuthError; + const statusCode = this.getStatusCodeForError(errorResult.code); + res.status(statusCode).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Login controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during login", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Validate JWT token + */ + validateToken = async (req: Request, res: Response): Promise => { + try { + const token = JWTService.extractTokenFromHeader( + req.headers.authorization + ); + if (!token) { + res.status(401).json({ + success: false, + message: "No token provided", + code: "NO_TOKEN", + }); + return; + } + + const result = await this.loginUseCase.validateToken(token); + if (result.success && result.data) { + res.status(200).json({ + success: true, + message: "Token is valid", + user: result.data, + }); + } else { + const errorResult = result as IAuthError; + res.status(401).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Token validation controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during token validation", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Refresh JWT token + */ + refreshToken = async (req: Request, res: Response): Promise => { + try { + const token = JWTService.extractTokenFromHeader( + req.headers.authorization + ); + if (!token) { + res.status(401).json({ + success: false, + message: "No token provided", + code: "NO_TOKEN", + }); + return; + } + + const result = await this.loginUseCase.refreshToken(token); + if (result.success && result.data) { + res.status(200).json({ + success: true, + message: "Token refreshed successfully", + token: result.data.token, + user: result.data.user, + }); + } else { + const errorResult = result as IAuthError; + res.status(401).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Token refresh controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during token refresh", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Logout user + */ + logout = async (req: Request, res: Response): Promise => { + try { + const token = JWTService.extractTokenFromHeader( + req.headers.authorization + ); + if (!token) { + res.status(401).json({ + success: false, + message: "No token provided", + code: "NO_TOKEN", + }); + return; + } + + const result = await this.loginUseCase.logout(token); + if (result.success) { + res.status(200).json({ + success: true, + message: "Logout successful", + }); + } else { + const errorResult = result as IAuthError; + res.status(400).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Logout controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during logout", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Validate wallet address format + */ + validateWalletFormat = async (req: Request, res: Response): Promise => { + try { + const { walletAddress } = req.params; + if (!walletAddress) { + res.status(400).json({ + success: false, + message: "Wallet address is required", + code: "MISSING_WALLET_ADDRESS", + }); + return; + } + + const validation = + WalletValidationService.validateWalletAddress(walletAddress); + res.status(200).json({ + success: true, + isValid: validation.isValid, + errors: validation.errors, + }); + } catch (error) { + console.error("Wallet validation controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during wallet validation", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Check wallet availability + */ + checkWalletAvailability = async ( + req: Request, + res: Response + ): Promise => { + try { + const { walletAddress } = req.params; + if (!walletAddress) { + res.status(400).json({ + success: false, + message: "Wallet address is required", + code: "MISSING_WALLET_ADDRESS", + }); + return; + } + + const result = + await this.registerUseCase.checkWalletAvailability(walletAddress); + if (result.success && result.data) { + res.status(200).json({ + success: true, + available: result.data.available, + message: result.data.available + ? "Wallet address is available" + : "Wallet address is already registered", + }); + } else { + const errorResult = result as IAuthError; + res.status(400).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Wallet availability check controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during wallet availability check", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Check email availability + */ + checkEmailAvailability = async ( + req: Request, + res: Response + ): Promise => { + try { + const { email } = req.params; + if (!email) { + res.status(400).json({ + success: false, + message: "Email is required", + code: "MISSING_EMAIL", + }); + return; + } + + const result = await this.registerUseCase.checkEmailAvailability(email); + if (result.success && result.data) { + res.status(200).json({ + success: true, + available: result.data.available, + message: result.data.available + ? "Email is available" + : "Email is already registered", + }); + } else { + const errorResult = result as IAuthError; + res.status(400).json({ + success: false, + message: errorResult.message, + code: errorResult.code, + details: errorResult.details, + }); + } + } catch (error) { + console.error("Email availability check controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error during email availability check", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Get user profile (protected route) + */ + getProfile = async (req: Request, res: Response): Promise => { + try { + // The user data is already attached by the auth middleware + const user = (req as { user?: IProfile }).user; + if (!user) { + res.status(401).json({ + success: false, + message: "User not authenticated", + code: "NOT_AUTHENTICATED", + }); + return; + } + + res.status(200).json({ + success: true, + message: "Profile retrieved successfully", + user, + }); + } catch (error) { + console.error("Get profile controller error:", error); + res.status(500).json({ + success: false, + message: "Internal server error while retrieving profile", + code: "INTERNAL_ERROR", + }); + } + }; + + /** + * Helper method to determine HTTP status code based on error code + */ + private getStatusCodeForError(code?: string): number { + switch (code) { + case "INVALID_WALLET_FORMAT": + case "INVALID_EMAIL_FORMAT": + case "MISSING_REQUIRED_FIELDS": + case "MISSING_CATEGORY": + return 400; + case "WALLET_ALREADY_REGISTERED": + case "EMAIL_ALREADY_REGISTERED": + return 409; + case "WALLET_NOT_FOUND": + case "USER_NOT_FOUND": + case "INVALID_TOKEN": + case "INVALID_REFRESH_TOKEN": + case "INVALID_LOGOUT_TOKEN": + return 401; + case "NO_TOKEN": + return 401; + default: + return 500; + } } -}; - -const validateWalletFormat = async (req: Request, res: Response) => { - const dto = await validateOr400(ValidateWalletFormatDto, req.body, res); - if (!dto) return; - - try { - // Validates wallet format using use case - const result = await validateWalletFormatUseCase.execute(dto); - const status = result.valid ? 200 : 400; - res.status(status).json(result); - } catch (err) { - const message = - err instanceof Error ? err.message : "Wallet format validation failed"; - res.status(500).json({ error: message }); + + /** + * Cleanup resources + */ + async cleanup(): Promise { + await Promise.all([ + this.loginUseCase.cleanup(), + this.registerUseCase.cleanup(), + ]); } -}; - -export default { - register, - login, - resendVerificationEmail, - verifyEmail, - verifyWallet, - validateWalletFormat, -}; +} + +export default new AuthController(); diff --git a/src/modules/auth/use-cases/login.usecase.ts b/src/modules/auth/use-cases/login.usecase.ts new file mode 100644 index 0000000..492998b --- /dev/null +++ b/src/modules/auth/use-cases/login.usecase.ts @@ -0,0 +1,222 @@ +import { + ILoginRequest, + ILoginResponse, + IProfile, + AuthResult, +} from "../domain/interfaces/auth.interface"; +import { AuthRepository } from "../infrastructure/repositories/auth.repository"; +import { JWTService } from "../domain/services/jwt.service"; +import { WalletValidationService } from "../domain/services/wallet-validation.service"; + +export class LoginUseCase { + private authRepository: AuthRepository; + + constructor() { + this.authRepository = new AuthRepository(); + } + + /** + * Executes the login process for a wallet-based authentication + * @param loginData - Login request data containing wallet address + * @returns Promise with login result + */ + async execute(loginData: ILoginRequest): Promise> { + try { + // Validate wallet address format + const walletValidation = WalletValidationService.validateWalletAddress( + loginData.walletAddress + ); + if (!walletValidation.isValid) { + return { + success: false, + message: "Invalid wallet address format", + code: "INVALID_WALLET_FORMAT", + details: { errors: walletValidation.errors }, + }; + } + + // Normalize wallet address + const normalizedWallet = WalletValidationService.normalizeWalletAddress( + loginData.walletAddress + ); + + // Find profile by wallet address + const profile = + await this.authRepository.findProfileByWallet(normalizedWallet); + if (!profile) { + return { + success: false, + message: "Wallet address not found. Please register first.", + code: "WALLET_NOT_FOUND", + }; + } + + // Generate JWT token + const token = JWTService.generateToken({ + userId: profile.wallet, // Use wallet as userId for JWT + email: profile.email, + profileType: profile.profileType, + }); + + // Prepare response + const response: ILoginResponse = { + success: true, + message: "Login successful", + token, + user: profile, + }; + + return { + success: true, + message: "Login successful", + data: response, + }; + } catch (error) { + console.error("Login use case error:", error); + + return { + success: false, + message: "Login failed. Please try again.", + code: "INTERNAL_ERROR", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Validates a JWT token and returns the associated profile + * @param token - JWT token to validate + * @returns Promise with profile validation result + */ + async validateToken(token: string): Promise> { + try { + // Verify and decode token + const payload = JWTService.verifyToken(token); + + // Find profile by wallet address from token + const profile = await this.authRepository.findProfileByWallet( + payload.userId + ); + if (!profile) { + return { + success: false, + message: "User not found", + code: "USER_NOT_FOUND", + }; + } + + return { + success: true, + message: "Token validated successfully", + data: profile, + }; + } catch (error) { + console.error("Token validation error:", error); + + return { + success: false, + message: "Invalid or expired token", + code: "INVALID_TOKEN", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Refreshes a JWT token + * @param token - Current JWT token + * @returns Promise with new token + */ + async refreshToken( + token: string + ): Promise> { + try { + // Validate current token + const validationResult = await this.validateToken(token); + if (!validationResult.success || !validationResult.data) { + return { + success: false, + message: "Invalid token for refresh", + code: "INVALID_REFRESH_TOKEN", + }; + } + + // Generate new token + const newToken = JWTService.generateToken({ + userId: validationResult.data.wallet, // Use wallet as userId + email: validationResult.data.email, + profileType: validationResult.data.profileType, + }); + + return { + success: true, + message: "Token refreshed successfully", + data: { + token: newToken, + user: validationResult.data, + }, + }; + } catch (error) { + console.error("Token refresh error:", error); + + return { + success: false, + message: "Failed to refresh token", + code: "REFRESH_FAILED", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Logs out a user by invalidating their token (client-side responsibility) + * @param token - JWT token to logout + * @returns Promise with logout result + */ + async logout(token: string): Promise> { + try { + // Validate token to ensure it was valid + const validationResult = await this.validateToken(token); + if (!validationResult.success) { + return { + success: false, + message: "Invalid token for logout", + code: "INVALID_LOGOUT_TOKEN", + }; + } + + // Note: In a production environment, you might want to implement + // a token blacklist or use Redis to track invalidated tokens + + return { + success: true, + message: "Logout successful", + data: { message: "User logged out successfully" }, + }; + } catch (error) { + console.error("Logout error:", error); + + return { + success: false, + message: "Logout failed", + code: "LOGOUT_FAILED", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Cleanup resources + */ + async cleanup(): Promise { + await this.authRepository.disconnect(); + } +} diff --git a/src/modules/auth/use-cases/register.usecase.ts b/src/modules/auth/use-cases/register.usecase.ts new file mode 100644 index 0000000..143b1f0 --- /dev/null +++ b/src/modules/auth/use-cases/register.usecase.ts @@ -0,0 +1,352 @@ +import { + IRegisterRequest, + IRegisterResponse, + AuthResult, +} from "../domain/interfaces/auth.interface"; +import { AuthRepository } from "../infrastructure/repositories/auth.repository"; +import { WalletValidationService } from "../domain/services/wallet-validation.service"; + +export class RegisterUseCase { + private authRepository: AuthRepository; + + constructor() { + this.authRepository = new AuthRepository(); + } + + /** + * Executes the registration process for a new user or organization + * @param registerData - Registration request data + * @returns Promise with registration result + */ + async execute( + registerData: IRegisterRequest + ): Promise> { + try { + // Validate wallet address format + const walletValidation = WalletValidationService.validateWalletAddress( + registerData.walletAddress + ); + if (!walletValidation.isValid) { + return { + success: false, + message: "Invalid wallet address format", + code: "INVALID_WALLET_FORMAT", + details: { errors: walletValidation.errors }, + }; + } + + // Validate email format + const emailValidation = this.validateEmail(registerData.email); + if (!emailValidation.isValid) { + return { + success: false, + message: "Invalid email format", + code: "INVALID_EMAIL_FORMAT", + details: { errors: emailValidation.errors }, + }; + } + + // Validate required fields based on profile type + const fieldValidation = this.validateRequiredFields(registerData); + if (!fieldValidation.isValid) { + return { + success: false, + message: "Missing required fields", + code: "MISSING_REQUIRED_FIELDS", + details: { errors: fieldValidation.errors }, + }; + } + + // Normalize data + const normalizedWallet = WalletValidationService.normalizeWalletAddress( + registerData.walletAddress + ); + const normalizedEmail = registerData.email.toLowerCase().trim(); + + // Check if wallet is already registered + const isWalletRegistered = + await this.authRepository.isWalletRegistered(normalizedWallet); + if (isWalletRegistered) { + return { + success: false, + message: "Wallet address is already registered", + code: "WALLET_ALREADY_REGISTERED", + }; + } + + // Check if email is already registered + const isEmailRegistered = + await this.authRepository.isEmailRegistered(normalizedEmail); + if (isEmailRegistered) { + return { + success: false, + message: "Email address is already registered", + code: "EMAIL_ALREADY_REGISTERED", + }; + } + + // Create profile based on type + let response: IRegisterResponse; + + if (registerData.profileType === "user") { + const user = await this.authRepository.createUser({ + name: registerData.name, + lastName: registerData.lastName, + email: normalizedEmail, + wallet: normalizedWallet, + }); + + response = { + success: true, + message: "User registered successfully", + profile: { + name: user.name, + email: user.email, + wallet: user.wallet, + profileType: "user", + }, + }; + } else { + // Organization registration + if (!registerData.category) { + return { + success: false, + message: "Category is required for organization registration", + code: "MISSING_CATEGORY", + }; + } + + const organization = await this.authRepository.createOrganization({ + name: registerData.name, + email: normalizedEmail, + wallet: normalizedWallet, + category: registerData.category, + }); + + response = { + success: true, + message: "Organization registered successfully", + profile: { + name: organization.name, + email: organization.email, + wallet: organization.wallet, + profileType: "organization", + category: organization.category, + }, + }; + } + + return { + success: true, + message: "Registration successful", + data: response, + }; + } catch (error) { + console.error("Registration use case error:", error); + + return { + success: false, + message: "Registration failed. Please try again.", + code: "INTERNAL_ERROR", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Validates email format + * @param email - Email to validate + * @returns Validation result + */ + private validateEmail(email: string): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!email) { + errors.push("Email is required"); + return { isValid: false, errors }; + } + + if (typeof email !== "string") { + errors.push("Email must be a string"); + return { isValid: false, errors }; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + errors.push("Invalid email format"); + } + + if (email.length > 254) { + errors.push("Email is too long (maximum 254 characters)"); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Validates required fields based on profile type + * @param registerData - Registration data to validate + * @returns Validation result + */ + private validateRequiredFields(registerData: IRegisterRequest): { + isValid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + // Validate name + if (!registerData.name || typeof registerData.name !== "string") { + errors.push("Name is required and must be a string"); + } else if (registerData.name.length < 2) { + errors.push("Name must be at least 2 characters long"); + } else if (registerData.name.length > 100) { + errors.push("Name cannot exceed 100 characters"); + } + + // Validate email + if (!registerData.email || typeof registerData.email !== "string") { + errors.push("Email is required and must be a string"); + } + + // Validate wallet address + if ( + !registerData.walletAddress || + typeof registerData.walletAddress !== "string" + ) { + errors.push("Wallet address is required and must be a string"); + } + + // Validate profile type + if ( + !registerData.profileType || + !["user", "project"].includes(registerData.profileType) + ) { + errors.push("Profile type must be either 'user' or 'project'"); + } + + // Validate organization-specific fields + if (registerData.profileType === "project") { + if (!registerData.category || typeof registerData.category !== "string") { + errors.push("Category is required for organization registration"); + } else if (registerData.category.length > 100) { + errors.push("Category cannot exceed 100 characters"); + } + } + + // Validate user-specific fields + if (registerData.profileType === "user" && registerData.lastName) { + if (typeof registerData.lastName !== "string") { + errors.push("Last name must be a string"); + } else if (registerData.lastName.length > 100) { + errors.push("Last name cannot exceed 100 characters"); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Checks if a wallet address is available for registration + * @param walletAddress - Wallet address to check + * @returns Promise with availability result + */ + async checkWalletAvailability( + walletAddress: string + ): Promise> { + try { + // Validate wallet address format + const walletValidation = + WalletValidationService.validateWalletAddress(walletAddress); + if (!walletValidation.isValid) { + return { + success: false, + message: "Invalid wallet address format", + code: "INVALID_WALLET_FORMAT", + details: { errors: walletValidation.errors }, + }; + } + + // Check if wallet is registered + const normalizedWallet = + WalletValidationService.normalizeWalletAddress(walletAddress); + const isRegistered = + await this.authRepository.isWalletRegistered(normalizedWallet); + + return { + success: true, + message: "Wallet availability checked successfully", + data: { available: !isRegistered }, + }; + } catch (error) { + console.error("Wallet availability check error:", error); + + return { + success: false, + message: "Failed to check wallet availability", + code: "AVAILABILITY_CHECK_FAILED", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Checks if an email address is available for registration + * @param email - Email address to check + * @returns Promise with availability result + */ + async checkEmailAvailability( + email: string + ): Promise> { + try { + // Validate email format + const emailValidation = this.validateEmail(email); + if (!emailValidation.isValid) { + return { + success: false, + message: "Invalid email format", + code: "INVALID_EMAIL_FORMAT", + details: { errors: emailValidation.errors }, + }; + } + + // Check if email is registered + const normalizedEmail = email.toLowerCase().trim(); + const isRegistered = + await this.authRepository.isEmailRegistered(normalizedEmail); + + return { + success: true, + message: "Email availability checked successfully", + data: { available: !isRegistered }, + }; + } catch (error) { + console.error("Email availability check error:", error); + + return { + success: false, + message: "Failed to check email availability", + code: "AVAILABILITY_CHECK_FAILED", + details: { + error: error instanceof Error ? error.message : "Unknown error", + }, + }; + } + } + + /** + * Cleanup resources + */ + async cleanup(): Promise { + await this.authRepository.disconnect(); + } +} diff --git a/src/modules/certificate/infrastructure/utils/pdfGenerator.ts b/src/modules/certificate/infrastructure/utils/pdfGenerator.ts index e15b200..5697ef5 100644 --- a/src/modules/certificate/infrastructure/utils/pdfGenerator.ts +++ b/src/modules/certificate/infrastructure/utils/pdfGenerator.ts @@ -1,7 +1,7 @@ import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; import fs from "fs"; import path from "path"; -// import { generateQRCode } from "./qrGenerator"; //Function not found, commented out +import { generateQRCode } from "../../../../shared/infrastructure/utils/qrGenerator"; import { format } from "date-fns"; export async function generateCertificate({ diff --git a/src/routes/OrganizationRoutes.ts b/src/routes/OrganizationRoutes.ts deleted file mode 100644 index 4f91a38..0000000 --- a/src/routes/OrganizationRoutes.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Router } from "express"; -import OrganizationController from "../modules/organization/presentation/controllers/OrganizationController.stub"; -import auth from "../middleware/authMiddleware"; - -const router = Router(); - -// Public routes -router.post("/", OrganizationController.createOrganization); -router.get("/", OrganizationController.getAllOrganizations); -router.get("/:id", OrganizationController.getOrganizationById); -router.get("/email/:email", OrganizationController.getOrganizationByEmail); - -// Protected routes -router.put( - "/:id", - auth.authMiddleware, - OrganizationController.updateOrganization -); -router.delete( - "/:id", - auth.authMiddleware, - OrganizationController.deleteOrganization -); - -export default router; diff --git a/src/routes/ProjectRoutes.ts b/src/routes/ProjectRoutes.ts deleted file mode 100644 index d48c4bd..0000000 --- a/src/routes/ProjectRoutes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Router } from "express"; -import ProjectController from "../modules/project/presentation/controllers/Project.controller.stub"; - -const router = Router(); -const projectController = new ProjectController(); - -router.post("/", async (req, res) => projectController.createProject(req, res)); -router.get("/:id", async (req, res) => - projectController.getProjectById(req, res) -); -router.get("/organizations/:organizationId", async (req, res) => - projectController.getProjectsByOrganizationId(req, res) -); - -export default router; diff --git a/src/routes/VolunteerRoutes.ts b/src/routes/VolunteerRoutes.ts deleted file mode 100644 index 24fe3b9..0000000 --- a/src/routes/VolunteerRoutes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router } from "express"; -import VolunteerController from "../modules/volunteer/presentation/controllers/VolunteerController.stub"; - -const router = Router(); -const volunteerController = new VolunteerController(); - -router.post("/", async (req, res) => - volunteerController.createVolunteer(req, res) -); -router.get("/:id", async (req, res) => - volunteerController.getVolunteerById(req, res) -); -router.get("/projects/:projectId", async (req, res) => - volunteerController.getVolunteersByProjectId(req, res) -); - -export default router; diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index 6336aeb..78b27e9 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -1,38 +1,64 @@ import { Router } from "express"; import AuthController from "../modules/auth/presentation/controllers/Auth.controller"; -// import authMiddleware from "../middleware/authMiddleware"; // Temporarily disabled +import authMiddleware from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth.types"; const router = Router(); -// Health check for auth module -router.get("/health", (req, res) => { - res.json({ status: "Auth module is available", module: "auth" }); -}); - -// Public routes (now using functional controller) +// Public routes router.post("/register", AuthController.register); router.post("/login", AuthController.login); +router.get( + "/validate-wallet/:walletAddress", + AuthController.validateWalletFormat +); +router.get( + "/check-wallet/:walletAddress", + AuthController.checkWalletAvailability +); +router.get("/check-email/:email", AuthController.checkEmailAvailability); -router.post("/send-verification-email", AuthController.resendVerificationEmail); -router.get("/verify-email/:token", AuthController.verifyEmail); -router.get("/verify-email", AuthController.verifyEmail); // Support query param method -router.post("/resend-verification", AuthController.resendVerificationEmail); - -// Wallet verification routes -router.post("/verify-wallet", AuthController.verifyWallet); -router.post("/validate-wallet-format", AuthController.validateWalletFormat); +// Protected routes +router.get( + "/profile", + authMiddleware.authMiddleware, + AuthController.getProfile +); +router.post( + "/validate-token", + authMiddleware.authMiddleware, + AuthController.validateToken +); +router.post( + "/refresh-token", + authMiddleware.authMiddleware, + AuthController.refreshToken +); +router.post("/logout", authMiddleware.authMiddleware, AuthController.logout); -// Protected routes - temporarily commented out due to interface mismatch -// router.get('/protected', authMiddleware.authMiddleware, AuthController.protectedRoute); -// router.get('/verification-status', authMiddleware.authMiddleware, AuthController.checkVerificationStatus); +// Routes requiring specific profile types +router.get( + "/user-only", + authMiddleware.authMiddleware, + authMiddleware.requireUserProfile, + (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + message: "User-only route accessed successfully", + }); + } +); -// Routes requiring verified email - temporarily commented out due to interface mismatch -// router.get('/verified-only', -// authMiddleware.authMiddleware, -// authMiddleware.requireVerifiedEmail, -// (req, res) => { -// res.json({ message: "You have access to this protected route because your email is verified!" }); -// } -// ); +router.get( + "/organization-only", + authMiddleware.authMiddleware, + authMiddleware.requireOrganizationProfile, + (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + message: "Organization-only route accessed successfully", + }); + } +); export default router; diff --git a/src/routes/certificatesRoutes.ts b/src/routes/certificatesRoutes.ts deleted file mode 100644 index 87ec7aa..0000000 --- a/src/routes/certificatesRoutes.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router } from "express"; -import { - downloadCertificate, - createCertificate, -} from "../modules/certificate/presentation/controllers/certificate.controller"; -import auth from "../middleware/authMiddleware"; - -const router = Router(); - -router.get("/volunteers/:id", auth.authMiddleware, downloadCertificate); -router.post("/volunteers/:id", auth.authMiddleware, createCertificate); - -export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 21cc85c..be90708 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,42 +1,37 @@ import { Router } from "express"; -import v1Router from "./v1"; +import authRoutes from "./authRoutes"; -const apiRouter = Router(); +const router = Router(); -/** - * API Versioning Router - * - * This router handles API versioning by namespacing routes under version prefixes. - * - * Current versions: - * - /v1/ - Current stable API version - * - /v2/ - Reserved for future expansion - */ - -// V1 API Routes -apiRouter.use("/v1", v1Router); - -// V2 API Routes (Reserved for future expansion) -// apiRouter.use("/v2", v2Router); +// Health check +router.get("/health", (req, res) => { + res.json({ + status: "API is running", + version: "1.0.0", + timestamp: new Date().toISOString(), + }); +}); -// Version info endpoint -apiRouter.get("/", (req, res) => { +// API version info +router.get("/", (req, res) => { res.json({ message: "VolunChain API", - versions: { - v1: { - status: "stable", - description: "Current stable API version", - endpoints: "/v1/", - }, - v2: { - status: "reserved", - description: "Reserved for future expansion", - endpoints: "/v2/", - }, - }, + version: "1.0.0", + status: "active", documentation: "/api/docs", }); }); -export default apiRouter; +// Mount all routes +router.use("/auth", authRoutes); + +// TODO: Add other routes as they are tested and ready +// router.use("/users", userRoutes); +// router.use("/organizations", organizationRoutes); +// router.use("/projects", projectRoutes); +// router.use("/volunteers", volunteerRoutes); +// router.use("/certificates", certificateRoutes); +// router.use("/nft", nftRoutes); +// router.use("/metrics", metricsRoutes); + +export default router; diff --git a/src/routes/nftRoutes.ts b/src/routes/nftRoutes.ts deleted file mode 100644 index 279cf05..0000000 --- a/src/routes/nftRoutes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Router } from "express"; -import NFTController from "../modules/nft/presentation/controllers/NFTController.stub"; -import { - validateDto, - validateParamsDto, -} from "../shared/middleware/validation.middleware"; -import { CreateNFTDto } from "../modules/nft/dto/create-nft.dto"; -import { UuidParamsDto } from "../shared/dto/base.dto"; - -const router = Router(); - -router.post("/nfts", validateDto(CreateNFTDto), NFTController.createNFT); - -router.get( - "/nfts/:id", - validateParamsDto(UuidParamsDto), - NFTController.getNFTById -); - -router.get( - "/users/:userId/nfts", - validateParamsDto(UuidParamsDto), - NFTController.getNFTsByUserId -); - -router.delete( - "/nfts/:id", - validateParamsDto(UuidParamsDto), - NFTController.deleteNFT -); - -export default router; diff --git a/src/routes/v2/organization.routes.ts b/src/routes/organization.routes.ts similarity index 100% rename from src/routes/v2/organization.routes.ts rename to src/routes/organization.routes.ts diff --git a/src/routes/testRoutes.ts b/src/routes/testRoutes.ts deleted file mode 100644 index 56101bc..0000000 --- a/src/routes/testRoutes.ts +++ /dev/null @@ -1,40 +0,0 @@ -import express from "express"; -import { prisma } from "../config/prisma"; - -const router = express.Router(); - -// Test route for database performance -router.get("/db-test", async (req, res) => { - try { - // Perform multiple queries to test connection pooling - const startTime = Date.now(); - - // Parallel queries to test connection pool - const [users, organizations, projects] = await Promise.all([ - prisma.user.findMany({ take: 5 }), - prisma.organization.findMany({ take: 5 }), - prisma.project.findMany({ take: 5 }), - ]); - - const endTime = Date.now(); - const duration = endTime - startTime; - - res.json({ - success: true, - duration: `${duration}ms`, - results: { - users: users.length, - organizations: organizations.length, - projects: projects.length, - }, - }); - } catch (error) { - console.error("Database test error:", error); - res.status(500).json({ - success: false, - error: "Database test failed", - }); - } -}); - -export default router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts deleted file mode 100644 index a30447a..0000000 --- a/src/routes/userRoutes.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - Router, - Request, - Response, - RequestHandler, - NextFunction, -} from "express"; -import UserController from "../modules/user/presentation/controllers/UserController.stub"; -import { authMiddleware } from "../middleware/authMiddleware"; -import { AuthenticatedRequest } from "../types/auth.types"; - -const userController = new UserController(); -const router = Router(); - -type AuthenticatedHandler = ( - req: AuthenticatedRequest, - res: Response -) => Promise; - -const wrapHandler = (handler: AuthenticatedHandler): RequestHandler => { - return ((req: Request, res: Response, next: NextFunction) => { - handler(req as AuthenticatedRequest, res).catch(next); - }) as unknown as RequestHandler; -}; - -// Public routes -router.post( - "/users", - wrapHandler(userController.createUser.bind(userController)) -); -router.get( - "/users/:id", - wrapHandler(userController.getUserById.bind(userController)) -); -router.get( - "/users/:email", - wrapHandler(userController.getUserByEmail.bind(userController)) -); - -// Protected routes -router.put( - "/users/:id", - authMiddleware, - wrapHandler(userController.updateUser.bind(userController)) -); - -export default router; diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts deleted file mode 100644 index 2dea990..0000000 --- a/src/routes/v1/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Router } from "express"; -import authRoutes from "../authRoutes"; -import nftRoutes from "../nftRoutes"; -import userRoutes from "../userRoutes"; -import metricsRoutes from "../../modules/metrics/routes/metrics.routes"; -import certificateRoutes from "../certificatesRoutes"; -import volunteerRoutes from "../VolunteerRoutes"; -import projectRoutes from "../ProjectRoutes"; -import organizationRoutes from "../OrganizationRoutes"; - -const v1Router = Router(); - -/** - * V1 API Routes - * All routes are namespaced under /v1/ - */ - -// Authentication routes - /v1/auth/* -v1Router.use("/auth", authRoutes); - -// NFT routes - /v1/nft/* -v1Router.use("/nft", nftRoutes); - -// User routes - /v1/users/* -v1Router.use("/users", userRoutes); - -// Metrics routes - /v1/metrics/* -v1Router.use("/metrics", metricsRoutes); - -// Certificate routes - /v1/certificate/* -v1Router.use("/certificate", certificateRoutes); - -// Project routes - /v1/projects/* -v1Router.use("/projects", projectRoutes); - -// Volunteer routes - /v1/volunteers/* -v1Router.use("/volunteers", volunteerRoutes); - -// Organization routes - /v1/organizations/* -v1Router.use("/organizations", organizationRoutes); - -export default v1Router; diff --git a/src/routes/v2/auth.routes.ts b/src/routes/v2/auth.routes.ts deleted file mode 100644 index 61ffa43..0000000 --- a/src/routes/v2/auth.routes.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Router } from "express"; -import { validateDto } from "../../shared/middleware/validation.middleware"; -import { RegisterDto } from "../../modules/auth/dto/register.dto"; -import { LoginDto } from "../../modules/auth/dto/login.dto"; -import { ResendVerificationDTO } from "../../modules/auth/dto/resendVerificationDTO"; -import { VerifyEmailDTO } from "../../modules/auth/dto/verifyEmailDTO"; -import { - ValidateWalletFormatDto, - VerifyWalletDto, -} from "../../modules/auth/dto/wallet-validation.dto"; - -const router = Router(); - -// Note: This is an example of how to properly integrate validation middleware -// The controller would need to be properly instantiated with dependencies - -// POST /auth/register - User registration -router.post( - "/register", - validateDto(RegisterDto) - // authController.register -); - -// POST /auth/login - User login -router.post( - "/login", - validateDto(LoginDto) - // authController.login -); - -// POST /auth/resend-verification - Resend email verification -router.post( - "/resend-verification", - validateDto(ResendVerificationDTO) - // authController.resendVerificationEmail -); - -// POST /auth/verify-email - Verify email with token -router.post( - "/verify-email", - validateDto(VerifyEmailDTO) - // authController.verifyEmail -); - -// POST /auth/validate-wallet-format - Validate wallet address format -router.post( - "/validate-wallet-format", - validateDto(ValidateWalletFormatDto) - // authController.validateWalletFormat -); - -// POST /auth/verify-wallet - Verify wallet ownership -router.post( - "/verify-wallet", - validateDto(VerifyWalletDto) - // authController.verifyWallet -); - -export default router; diff --git a/src/shared/middleware/validation.middleware.ts b/src/shared/middleware/validation.middleware.ts index 54fb7cc..debe24b 100644 --- a/src/shared/middleware/validation.middleware.ts +++ b/src/shared/middleware/validation.middleware.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { validate, ValidationError } from "class-validator"; import { plainToClass } from "class-transformer"; +import { ParsedQs } from "qs"; export interface ValidationErrorResponse { success: false; @@ -77,7 +78,7 @@ export function validateQueryDto(dtoClass: new () => T) { return; } - req.query = dto as Record; + req.query = dto as unknown as ParsedQs; next(); } catch { res.status(500).json({ @@ -125,3 +126,46 @@ export function validateParamsDto(dtoClass: new () => T) { } }; } + +/** + * Validates a DTO and returns it if valid, or sends a 400 response if invalid + * @param dtoClass - The DTO class to validate against + * @param payload - The payload to validate + * @param res - Express response object + * @returns The validated DTO or undefined if validation failed + */ +export async function validateOr400( + dtoClass: new () => T, + payload: unknown, + res: Response +): Promise { + try { + const dto = plainToClass(dtoClass, payload); + const errors = await validate(dto); + + if (errors.length > 0) { + const errorResponse: ValidationErrorResponse = { + success: false, + error: "Validation failed", + details: errors.map((error: ValidationError) => ({ + property: error.property, + value: error.value, + constraints: error.constraints + ? Object.values(error.constraints) + : [], + })), + }; + + res.status(400).json(errorResponse); + return undefined; + } + + return dto; + } catch { + res.status(500).json({ + success: false, + error: "Internal server error during validation", + }); + return undefined; + } +} diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index 67a1c5b..a7fb908 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -12,10 +12,9 @@ import { Request } from "express"; * This interface combines all user properties needed across the application */ export interface AuthenticatedUser { - id: string | number; + userId: string; email: string; - role: string; - isVerified: boolean; + profileType: "user" | "organization"; } /** @@ -31,32 +30,34 @@ export interface AuthenticatedRequest extends Request { * Decoded JWT user interface (from auth middleware) */ export interface DecodedUser { - id: string | number; + userId: string; email: string; - role?: string; - isVerified?: boolean; + profileType: "user" | "organization"; iat?: number; exp?: number; } // Legacy user interface for backward compatibility export interface LegacyUser { - id: number | string; - role: string; - isVerified?: boolean; - email?: string; + userId: string; + profileType: "user" | "organization"; + email: string; } /** * Type guard to check if user has required authentication properties */ -export function isAuthenticatedUser(user: any): user is AuthenticatedUser { +export function isAuthenticatedUser(user: unknown): user is AuthenticatedUser { return ( - user && - (typeof user.id === "string" || typeof user.id === "number") && - typeof user.email === "string" && - typeof user.role === "string" && - typeof user.isVerified === "boolean" + user !== null && + typeof user === "object" && + "userId" in user && + "email" in user && + "profileType" in user && + typeof (user as Record).userId === "string" && + typeof (user as Record).email === "string" && + ((user as Record).profileType === "user" || + (user as Record).profileType === "organization") ); } @@ -67,10 +68,9 @@ export function toAuthenticatedUser( decodedUser: DecodedUser ): AuthenticatedUser { return { - id: decodedUser.id, + userId: decodedUser.userId, email: decodedUser.email, - role: decodedUser.role || "user", - isVerified: decodedUser.isVerified || false, + profileType: decodedUser.profileType, }; } @@ -78,18 +78,15 @@ export function toAuthenticatedUser( * Helper function to convert AuthenticatedUser to LegacyUser for backward compatibility */ export const toLegacyUser = (user: AuthenticatedUser): LegacyUser => ({ - id: user.id, - role: user.role, - isVerified: user.isVerified, + userId: user.userId, + profileType: user.profileType, email: user.email, }); -// Global Express namespace extension -declare global { - namespace Express { - interface Request { - user?: AuthenticatedUser; - traceId?: string; - } +// Global Express interface extension +declare module "express-serve-static-core" { + interface Request { + user?: AuthenticatedUser; + traceId?: string; } }