diff --git a/src/app.js b/src/app.js index dc07a23..b9f047f 100644 --- a/src/app.js +++ b/src/app.js @@ -16,7 +16,7 @@ app.use("/api/v1/local", express.static(path.join(__dirname, "app/local"))); app.get("/", (req, res) => { res.json({ success: true, - message: "Welcome to the Learnup Bangladesh API v1.0", + message: "Welcome to the Website Builder API v1.0", }); }); diff --git a/src/app/config/firebase.js b/src/app/config/firebase.js index 012fa50..cf2f89b 100644 --- a/src/app/config/firebase.js +++ b/src/app/config/firebase.js @@ -1,5 +1,5 @@ const admin = require("firebase-admin"); -const serviceAccount = require("../../../path/to/firebaseServiceAccount.json"); +const serviceAccount = require("./website-builder.json"); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), diff --git a/src/app/config/website-builder.json b/src/app/config/website-builder.json new file mode 100644 index 0000000..bf1c41f --- /dev/null +++ b/src/app/config/website-builder.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "website-builder-dev-bd652", + "private_key_id": "51696b3e6b19bcc14f625b39a3e8ae43e1ccdffd", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1m0XQWkn3Cef0\nZ2OEmzMmlTALUeM4taDsX/CJStoI7A9f1P5okekWcWMbTWXTLP1vTxcMIn8/I6ke\nVWIsQnpCC5JthyySS8e7z6kiIzRdb8Vbvto0pNirCF/T4+lYD096OPs+WERXzlu+\nby1turUEfvbWUbzH8jtpRngnw9EFUmDszanmWFZdfA/kNBoEb5hgjtQBOc7RHnxW\nroTYZSarkCQ3344VUnJmR7EPT2tDMwJo6/H1ljTcCoxPE6FAgjQdhuhvWFxvvFzS\nI5dt83pnGJDqIh40KEBLXROtUJsRqTKD39gU64BwF3/FGDW53VRgNwV3yYd9yw7l\nNkUdumftAgMBAAECggEAJYV9wKUO+CQLUSNVW+4G/PdA9G3IUQyxwXS3CV62Orav\nHIEwySEwc+sca2Ur3xxCBo/kNqUf/kcbZ4GDpZpckS275LZTfqx6iXLHwp5ccNup\nfqGpisvuWjQCPJCvwvpy1NFWE5UAFdY0fV2SGMRFJzcmP7SmmPcS0Fb3d1vNYVhA\ntp17dpG7q7CuKvrfcXURRVEBevq3jl0JDfIyO4au45ZXV0Q7G+k/P1nf8TX0KsSL\nzUBFuUKT2WDr9I6RyigutH0OIqI6tAJyk3V/K6BR9BwOpYyFTkmigd4756jyx5oZ\nhGo5lqs1yn8xDwclTAAz+qtFeU6ahxrPFHa6v1F5EQKBgQDxM5+uK0Lz23TO+kML\n91LDJ1Qyh9dNsG8NY7Q1jA0yCZntL4BuAgj2A0MK415zA/q9Jg/JWkykz0VVzJJW\neMYYegVxrQvVK2mB4M1Ip9U3rM7UHsrEluxM7nMyNQ4Cd4TXHoB/ZwQQ747Y74th\n6htjVr2gAavzbAo5yJqauR+JSQKBgQDAv6HlYfSukbbA4x2eTLQdybSPm6h3XttT\nZzCROsMqQU8HRgOX0yZ8yC7jupAAfGMN12LUCdEn/aF7t2xT9uDHe9hOsW18LIIl\nyOx1anrVZOqsCpbBQJ0V2Q6QKlwXodk9gUOXi2nVKp/ITbRy4GBM9iqLjlU90xdy\nq9X/L8RthQKBgQCvNWbK59YMud+R8iz95jySUmFRC2gUoRMqUMC5HPEA5gSQTK8o\nOgY0Xo42vI8BUWS0PY++HgAKwB2Hg8DRW43afdiyiJdN0+kiVSAJpRC3Dqp3X56/\nSzp/b77yO6Pfmt4+PYPuB/DmNH45i1heWeZnNL0uG8jCXDrZWqUju1HzMQKBgBKX\n6dQo2OOmJs0Am7DTkWR2OrbHuz6YpeurvqGj24PN/QOkm2Af/Ex5Oxy4uH9zgFKr\nflsZ/1UuQE+g6BBJdnGH3tvofblGyd8/PKAu/15qd8DU3KoTw5OB9setbmjRMhWe\nzJhn2HO5wuQdqtSSFHgYHw6LUmm+XTqwnNobD4XJAoGBANmoazI2EibjUB2cH1IA\ngZ0+3adadd40akbqm7nPwsJqYgFZRB+4btsDD6OcrhuOHVeLsLXEBrZZJum1h+9S\nx9xzBmIcFSHQiEhpA+lqHE0dEC3u3oQ22GC1YtXQlvVl7ZbUO7UwutE3GLclH++H\nN/UeXVZNm+/Ot3bn2/7d3Ep3\n-----END PRIVATE KEY-----\n", + "client_email": "firebase-adminsdk-fbsvc@website-builder-dev-bd652.iam.gserviceaccount.com", + "client_id": "110829170051006661307", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40website-builder-dev-bd652.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/src/app/middleware/projectAuth.js b/src/app/middleware/projectAuth.js new file mode 100644 index 0000000..92afcff --- /dev/null +++ b/src/app/middleware/projectAuth.js @@ -0,0 +1,99 @@ +// middleware/projectAuth.js +const httpStatus = require("http-status"); +const supabase = require("../config/supabaseClient"); +const AppError = require("../errors/AppError"); +const catchAsync = require("../utils/catchAsync"); +const { verifyToken } = require("../utils/jwt"); + +const projectCustomerAuth = () => { + return catchAsync(async (req, res, next) => { + const token = req.headers.authorization?.split(" ")[1]; + + if (!token) { + throw new AppError(httpStatus.UNAUTHORIZED, "Access token required"); + } + + try { + const decoded = verifyToken(token); + + if (decoded.type !== "project_member") { + throw new AppError(httpStatus.UNAUTHORIZED, "Invalid token type"); + } + + const { userId, projectId } = decoded; + + const { data: user, error: userError } = await supabase + .from("users") + .select("*") + .eq("id", userId) + .single(); + + if (userError || !user) { + throw new AppError(httpStatus.UNAUTHORIZED, "User not found"); + } + + const { data: project, error: projectError } = await supabase + .from("projects") + .select("*") + .eq("id", projectId) + .eq("is_published", true) + .single(); + + if (projectError || !project) { + throw new AppError( + httpStatus.NOT_FOUND, + "Project not found or not available" + ); + } + + const { data: membership, error: memberError } = await supabase + .from("project_members") + .select("*") + .eq("project_id", projectId) + .eq("user_id", userId) + .single(); + + if (memberError || !membership) { + throw new AppError( + httpStatus.FORBIDDEN, + "Not a member of this project" + ); + } + + if (!user.is_verified) { + throw new AppError(httpStatus.FORBIDDEN, "Email not verified"); + } + + if (user.status !== "active") { + throw new AppError(httpStatus.FORBIDDEN, "Account not active"); + } + + req.user = { + userId: user.id, + email: user.email, + name: user.name, + role: user.role, + projectId: projectId, + projectRole: membership.role, + }; + + req.project = { + id: project.id, + name: project.name, + domain: project.domain, + subdomain: project.subdomain, + }; + + next(); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError(httpStatus.UNAUTHORIZED, "Invalid token"); + } + }); +}; + +module.exports = { + projectCustomerAuth, +}; diff --git a/src/app/middleware/subdomainDetection.js b/src/app/middleware/subdomainDetection.js new file mode 100644 index 0000000..d7a4fce --- /dev/null +++ b/src/app/middleware/subdomainDetection.js @@ -0,0 +1,78 @@ +const httpStatus = require("http-status"); +const supabase = require("../config/supabaseClient"); +const AppError = require("../errors/AppError"); +const catchAsync = require("../utils/catchAsync"); + +const detectProjectFromSubdomain = () => { + return catchAsync(async (req, res, next) => { + const host = req.get("host") || req.get("x-forwarded-host") || ""; + const origin = req.get("origin") || req.get("referer") || ""; + const protocol = req.get("x-forwarded-proto") || req.protocol || "https"; + + const frontendUrl = origin || `${protocol}://${host}`; + + console.log("Frontend URL:", frontendUrl); + console.log("Host:", host); + console.log("Origin:", origin); + console.log("Protocol:", protocol); + + let subdomain = null; + let isCustomDomain = false; + + const hostParts = host.split("."); + if (hostParts.length >= 3) { + subdomain = hostParts[0]; + } else { + isCustomDomain = true; + } + + let project = null; + + if (isCustomDomain) { + const { data: customDomainProject, error: customError } = await supabase + .from("projects") + .select("*") + .eq("domain", host) + .eq("is_published", true) + .eq("status", "active") + .single(); + + if (!customError && customDomainProject) { + project = customDomainProject; + } + } + + if (!project && subdomain) { + const { data: subdomainProject, error: subdomainError } = await supabase + .from("projects") + .select("*") + .eq("sub_domain", subdomain) + .eq("is_published", true) + .eq("status", "active") + .single(); + + if (!subdomainError && subdomainProject) { + project = subdomainProject; + } + } + + if (!project) { + throw new AppError( + httpStatus.NOT_FOUND, + `No active project found for domain: ${host}. Please check the URL.` + ); + } + + req.project = project; + req.projectId = project.id; + req.frontendUrl = frontendUrl; + req.subdomain = subdomain; + req.host = host; + + next(); + }); +}; + +module.exports = { + detectProjectFromSubdomain, +}; diff --git a/src/app/modules/Auth/auth.controller.js b/src/app/modules/Auth/auth.controller.js index 0139df2..d275720 100644 --- a/src/app/modules/Auth/auth.controller.js +++ b/src/app/modules/Auth/auth.controller.js @@ -1,90 +1,111 @@ -//auth.controller.js - -const transporter = require("../../utils/transporterMail"); - const catchAsync = require("../../utils/catchAsync"); const { createToken, verifyToken } = require("../../utils/jwt"); -const sendConfirmationEmail = require("../../utils/sendConfirmationEmail"); const sendResponse = require("../../utils/sendResponse"); +const sendConfirmationEmail = require("../../utils/sendConfirmationEmail"); +const sendResetPassEmail = require("../../utils/sendResetPassEmail"); const { registerUser, loginUser, firebaseLogin: firebaseAuth, } = require("./auth.service"); -const httpStatus = require("http-status").default; +const httpStatus = require("http-status"); const AppError = require("../../errors/AppError"); -const hashPassword = require("../../utils/hashedPassword"); +const supabase = require("../../config/supabaseClient"); +const bcrypt = require("bcrypt"); const { client_url } = require("../../config"); -const sendResetPassEmail = require("../../utils/sendResetPassEmail"); - -const login = catchAsync(async (req, res) => { - const loginData = req.body; - const result = await loginUser(loginData); - - sendResponse(res, { - success: true, - message: "Login successful", - data: result, - statusCode: 200, - }); -}); - -const firebaseLogin = catchAsync(async (req, res) => { - const firebasePayload = req.body; - const result = await firebaseAuth(firebasePayload); - - sendResponse(res, { - success: true, - message: "Login successful", - data: result, - statusCode: 200, - }); -}); const register = catchAsync(async (req, res) => { - const registerData = req.body; - const user = await registerUser(registerData); - const verificationToken = createToken({ email: user.email, role: user.role }); - // sendConfirmationEmail(user.email, verificationToken); + const user = await registerUser(req.body); + + const verificationToken = createToken( + { email: user.email, userId: user.id }, + "24h" + ); + + await sendConfirmationEmail(user.email, verificationToken); + sendResponse(res, { success: true, - message: "Registration successful.", - data: user, + message: + "Registration successful. Please check your email for verification.", + data: { id: user.id, name: user.name, email: user.email }, statusCode: httpStatus.CREATED, }); }); -const verfiyEmail = catchAsync(async (req, res) => { - const token = req.query.token; - const decoded = verifyToken(token); - const { email } = decoded; - const user = await UserModel.findOne({ email }); - if (!user) { - throw new AppError(httpStatus.NOT_FOUND, "User not found"); - } - if (user.isVerified) { - throw new AppError(httpStatus.BAD_REQUEST, "User already verified"); - } - user.isVerified = true; - await user.save(); +const login = catchAsync(async (req, res) => { + const result = await loginUser(req.body); + sendResponse(res, { success: true, - message: "Email verified successfully and account activated", + message: "Login successful", + data: result, + statusCode: httpStatus.OK, + }); +}); + +const firebaseLogin = catchAsync(async (req, res) => { + const result = await firebaseAuth(req.body); + + sendResponse(res, { + success: true, + message: "Login successful", + data: result, + statusCode: httpStatus.OK, + }); +}); + +const verifyEmail = catchAsync(async (req, res) => { + const { token } = req.query; + + if (!token) { + throw new AppError(httpStatus.BAD_REQUEST, "Verification token required"); + } + + const decoded = verifyToken(token); + + const { error } = await supabase + .from("users") + .update({ is_verified: true }) + .eq("email", decoded.email) + .eq("id", decoded.userId); + + if (error) { + throw new AppError(httpStatus.BAD_REQUEST, "Verification failed"); + } + + sendResponse(res, { + success: true, + message: "Email verified successfully", data: null, statusCode: httpStatus.OK, }); }); const resendVerification = catchAsync(async (req, res) => { - const user = await UserModel.findOne({ email: req.body.email }); - if (!user) { + const { email } = req.body; + + const { data: user, error } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); + + if (error || !user) { throw new AppError(httpStatus.NOT_FOUND, "User not found"); } - if (user.isVerified) { + + if (user.is_verified) { throw new AppError(httpStatus.BAD_REQUEST, "User already verified"); } - const verificationToken = createToken({ email: user.email, role: user.role }); - sendConfirmationEmail(user.email, verificationToken); + + const verificationToken = createToken( + { email: user.email, userId: user.id }, + "24h" + ); + + await sendConfirmationEmail(user.email, verificationToken); + sendResponse(res, { success: true, message: "Verification email sent", @@ -95,11 +116,27 @@ const resendVerification = catchAsync(async (req, res) => { const forgotPassword = catchAsync(async (req, res) => { const { email } = req.body; - const user = await UserModel.findOne({ email }); - if (!user) return res.status(404).json({ message: "User not found" }); - const token = createToken({ email: user.email }, "15m"); - const resetLink = `${client_url}/reset-password?token=${token}`; - await sendResetPassEmail(user.email, resetLink); + + const { data: user } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); + + if (!user) { + sendResponse(res, { + success: true, + message: "If email exists, reset link has been sent", + data: null, + statusCode: httpStatus.OK, + }); + return; + } + + const resetToken = createToken({ email, userId: user.id }, "15m"); + const resetLink = `${client_url}/reset-password?token=${resetToken}`; + + await sendResetPassEmail(email, resetLink); sendResponse(res, { success: true, @@ -111,14 +148,20 @@ const forgotPassword = catchAsync(async (req, res) => { const resetPassword = catchAsync(async (req, res) => { const { newPassword, token } = req.body; - const decoded = verifyToken(token); - const user = await UserModel.findOne({ email: decoded.email }); - if (!user) return res.status(404).json({ message: "User not found" }); - await UserModel.updateOne( - { email: user.email }, - { $set: { password: await hashPassword(newPassword) } } - ); + const decoded = verifyToken(token); + const hashedPassword = await bcrypt.hash(newPassword, 10); + + const { error } = await supabase + .from("users") + .update({ password_hash: hashedPassword }) + .eq("email", decoded.email) + .eq("id", decoded.userId); + + if (error) { + throw new AppError(httpStatus.BAD_REQUEST, "Password reset failed"); + } + sendResponse(res, { success: true, message: "Password reset successful", @@ -127,13 +170,36 @@ const resetPassword = catchAsync(async (req, res) => { }); }); +const getProfile = catchAsync(async (req, res) => { + const { data: user, error } = await supabase + .from("users") + .select( + "id, name, email, phone, role, is_verified, profile_picture, created_at" + ) + .eq("id", req.user.userId) + .single(); + + if (error) { + throw new AppError(httpStatus.NOT_FOUND, "User not found"); + } + + sendResponse(res, { + success: true, + message: "Profile retrieved successfully", + data: user, + statusCode: httpStatus.OK, + }); +}); + const AuthController = { - login, register, - verfiyEmail, + login, + firebaseLogin, + verfiyEmail: verifyEmail, resendVerification, forgotPassword, resetPassword, - firebaseLogin, + getProfile, }; + module.exports = AuthController; diff --git a/src/app/modules/Auth/auth.routes.js b/src/app/modules/Auth/auth.routes.js index d5678c6..ebbbcd3 100644 --- a/src/app/modules/Auth/auth.routes.js +++ b/src/app/modules/Auth/auth.routes.js @@ -33,9 +33,6 @@ router.post( AuthController.resetPassword ); -const authRoutes = router; -module.exports = authRoutes; - /** * @swagger * /auth/login: @@ -166,6 +163,8 @@ module.exports = authRoutes; * type: string * name: * type: string + * phone: + * type: string * responses: * 200: * description: Successful login @@ -174,3 +173,6 @@ module.exports = authRoutes; * 401: * description: Invalid Firebase token */ + +const authRoutes = router; +module.exports = authRoutes; diff --git a/src/app/modules/Auth/auth.service.js b/src/app/modules/Auth/auth.service.js index f5fcd44..79a9069 100644 --- a/src/app/modules/Auth/auth.service.js +++ b/src/app/modules/Auth/auth.service.js @@ -4,9 +4,10 @@ const bcrypt = require("bcrypt"); const compareValidPass = require("../../utils/validPass"); const { createToken } = require("../../utils/jwt"); const supabase = require("../../config/supabaseClient"); -const UserService = require("../User/user.service"); +const admin = require("../../config/firebase"); const registerUser = async (payload) => { + // Check if user exists const { data: existingUser } = await supabase .from("users") .select("*") @@ -17,30 +18,67 @@ const registerUser = async (payload) => { throw new AppError(httpStatus.BAD_REQUEST, "User already exists"); } + // Hash password const hashedPassword = await bcrypt.hash(payload.password, 10); - const { data: createdUser, error } = await supabase + // Create user + const { data: user, error: userError } = await supabase .from("users") .insert({ name: payload.name, email: payload.email, phone: payload.phone, - password: hashedPassword, + password_hash: hashedPassword, + role: "admin", + is_verified: false, }) .select() .single(); - if (error) { + if (userError) { throw new AppError( httpStatus.INTERNAL_SERVER_ERROR, "User creation failed" ); } - return createdUser; + // Create default project + const projectName = `${payload.name}'s Store`; + const subdomain = + payload.email.split("@")[0].toLowerCase() + Date.now().toString().slice(-4); + + const { data: project, error: projectError } = await supabase + .from("projects") + .insert({ + owner_id: user.id, + name: projectName, + sub_domain: subdomain, + status: "draft", + }) + .select() + .single(); + + if (projectError) { + // Clean up user if project fails + await supabase.from("users").delete().eq("id", user.id); + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "Project creation failed" + ); + } + + // Add user as project owner + await supabase.from("project_members").insert({ + project_id: project.id, + user_id: user.id, + role: "owner", + }); + + return user; }; const loginUser = async (payload) => { + // Find user const { data: user, error } = await supabase .from("users") .select("*") @@ -48,82 +86,106 @@ const loginUser = async (payload) => { .single(); if (error || !user) { - throw new AppError(httpStatus.BAD_REQUEST, "User does not exist"); + throw new AppError(httpStatus.BAD_REQUEST, "Invalid credentials"); } if (!user.is_verified) { throw new AppError( httpStatus.BAD_REQUEST, - "Please verify your email before logging in" + "Please verify your email first" ); } - const isMatch = await compareValidPass(payload.password, user.password); + // Check password + const isMatch = await compareValidPass(payload.password, user.password_hash); if (!isMatch) { - throw new AppError(httpStatus.BAD_REQUEST, "Password does not match"); + throw new AppError(httpStatus.BAD_REQUEST, "Invalid credentials"); } + // Create token const token = createToken({ - email: user.email, userId: user.id, + email: user.email, role: user.role, }); - const { password, ...userData } = user; + const { password_hash, ...userData } = user; return { - ...userData, + user: userData, accessToken: token, }; }; -const firebaseLogin = async ({ firebaseToken, email, name }) => { - if (!firebaseToken || !email || !name) { - throw new AppError( - httpStatus.BAD_REQUEST, - "firebaseToken, email, and name are required" - ); +const firebaseLogin = async ({ firebaseToken, email, name, phone }) => { + // Verify Firebase token + const decodedToken = await admin.auth().verifyIdToken(firebaseToken); + + if (decodedToken.email !== email) { + throw new AppError(httpStatus.UNAUTHORIZED, "Invalid Firebase token"); } - // 1. Verify Firebase Token - // const decodedFirebaseToken = await admin.auth().verifyIdToken(firebaseToken); + let { data: user } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); - // if (!decodedFirebaseToken || decodedFirebaseToken.email !== email) { - // throw new AppError(httpStatus.UNAUTHORIZED, "Invalid Firebase token"); - // } - - // 2. Check if user exists - let user = await UserService.findUserByEmail(email); - - // 3. Create user if doesn't exist if (!user) { - user = await UserService.createUser({ - name, - email, - is_verified: true, - role: "user", - is_deleted: false, - needs_password_change: false, + const { data: newUser, error } = await supabase + .from("users") + .insert({ + name, + email, + phone, + role: "admin", + is_verified: true, + }) + .select() + .single(); + + if (error) { + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "User creation failed" + ); + } + + const projectName = `${name}'s Store`; + const subdomain = + email.split("@")[0].toLowerCase() + Date.now().toString().slice(-4); + + const { data: project, error: prerror } = await supabase + .from("projects") + .insert({ + owner_id: newUser.id, + name: projectName, + sub_domain: subdomain, + status: "draft", + }) + .select() + .single(); + + await supabase.from("project_members").insert({ + project_id: project.id, + user_id: newUser.id, + role: "owner", }); + + user = newUser; } - // 4. Generate access & refresh tokens - const payload = { + const token = createToken({ userId: user.id, email: user.email, role: user.role, - }; + }); - const accessToken = createToken(payload); - // const refreshToken = createToken( - // payload, - // config.jwt.refresh_secret, - // config.jwt.refresh_expires_in - // ); + const { password_hash, ...userData } = user; return { - accessToken, - user, + user: userData, + accessToken: token, }; }; diff --git a/src/app/modules/Auth/auth.validation.js b/src/app/modules/Auth/auth.validation.js index 3a5be2b..06b086a 100644 --- a/src/app/modules/Auth/auth.validation.js +++ b/src/app/modules/Auth/auth.validation.js @@ -1,31 +1,37 @@ -//auth.validation.js - const z = require("zod"); -const loginSchema = z.object({ - email: z.string().email().min(1), - password: z.string().min(1), +const registerSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Valid email required"), + password: z.string().min(6, "Password must be at least 6 characters"), + phone: z.string().optional(), }); -const registerSchema = z.object({ - name: z.string().min(1), - email: z.string().email().min(1), - password: z.string().min(1), +const loginSchema = z.object({ + email: z.string().email("Valid email required"), + password: z.string().min(1, "Password is required"), }); + +const firebaseLoginSchema = z.object({ + firebaseToken: z.string().min(1, "Firebase token required"), + email: z.string().email("Valid email required"), + name: z.string().min(1, "Name is required"), + phone: z.string().optional(), +}); + const resendVerificationSchema = z.object({ - email: z - .string({ message: "Please enter a valid email address" }) - .email() - .min(1), + email: z.string().email("Valid email required"), }); + const resetPasswordSchema = z.object({ - newPassword: z.string().min(1), - token: z.string().min(1), + newPassword: z.string().min(6, "Password must be at least 6 characters"), + token: z.string().min(1, "Token is required"), }); module.exports = { - loginSchema, registerSchema, + loginSchema, + firebaseLoginSchema, resendVerificationSchema, resetPasswordSchema, }; diff --git a/src/app/modules/projectAuth/projectAuth.controller.js b/src/app/modules/projectAuth/projectAuth.controller.js new file mode 100644 index 0000000..6cbac50 --- /dev/null +++ b/src/app/modules/projectAuth/projectAuth.controller.js @@ -0,0 +1,293 @@ +const catchAsync = require("../../utils/catchAsync"); +const { createToken, verifyToken } = require("../../utils/jwt"); +const sendResponse = require("../../utils/sendResponse"); +const sendConfirmationEmail = require("../../utils/sendConfirmationEmail"); +const sendResetPassEmail = require("../../utils/sendResetPassEmail"); +const httpStatus = require("http-status"); +const AppError = require("../../errors/AppError"); +const { client_url } = require("../../config"); +const supabase = require("../../config/supabaseClient"); +const { + registerProjectCustomer, + loginProjectCustomer, + findUserByEmail, + updateUserPassword, + verifyUserEmail, + checkProjectMembership, +} = require("./projectAuth.service"); + +const register = catchAsync(async (req, res) => { + const { projectId } = req.params; + const result = await registerProjectCustomer(projectId, req.body); + + if (!result.user.is_verified) { + const verificationToken = createToken( + { + userId: result.user.id, + email: result.user.email, + projectId: projectId, + type: "project_customer_verification", + }, + "24h" + ); + + await sendConfirmationEmail(result.user.email, verificationToken); + } + + const { password_hash, ...userResponse } = result.user; + + let message; + if (result.isNewUser) { + message = + "Registration successful. Please check your email for verification."; + } else if (!result.user.is_verified) { + message = + "You've been added to this project. Please check your email for verification."; + } else { + message = + "You've been successfully registered for this project and can login now."; + } + + sendResponse(res, { + success: true, + message, + data: { + user: userResponse, + project: result.project, + isNewUser: result.isNewUser, + needsVerification: !result.user.is_verified, + }, + statusCode: httpStatus.CREATED, + }); +}); + +// Login customer to a specific project +const login = catchAsync(async (req, res) => { + const { projectId } = req.params; + const result = await loginProjectCustomer(projectId, req.body); + + sendResponse(res, { + success: true, + message: "Login successful", + data: result, + statusCode: httpStatus.OK, + }); +}); + +// Verify customer email +const verifyEmail = catchAsync(async (req, res) => { + const { token } = req.query; + + if (!token) { + throw new AppError( + httpStatus.BAD_REQUEST, + "Verification token is required" + ); + } + + const decoded = verifyToken(token); + + if (decoded.type !== "project_customer_verification") { + throw new AppError(httpStatus.BAD_REQUEST, "Invalid verification token"); + } + + await verifyUserEmail(decoded.userId); + + sendResponse(res, { + success: true, + message: "Email verified successfully. You can now login.", + data: null, + statusCode: httpStatus.OK, + }); +}); + +// Resend verification email +const resendVerification = catchAsync(async (req, res) => { + const { projectId } = req.params; + const { email } = req.body; + + const user = await findUserByEmail(email); + + if (!user) { + throw new AppError(httpStatus.NOT_FOUND, "User not found"); + } + + // Check if user is member of this project + const membership = await checkProjectMembership(user.id, projectId); + if (!membership) { + throw new AppError( + httpStatus.NOT_FOUND, + "User not registered for this project" + ); + } + + if (user.is_verified) { + throw new AppError(httpStatus.BAD_REQUEST, "Email already verified"); + } + + const verificationToken = createToken( + { + userId: user.id, + email: user.email, + projectId: projectId, + type: "project_customer_verification", + }, + "24h" + ); + + await sendConfirmationEmail(user.email, verificationToken); + + sendResponse(res, { + success: true, + message: "Verification email sent successfully", + data: null, + statusCode: httpStatus.OK, + }); +}); + +// Forgot password +const forgotPassword = catchAsync(async (req, res) => { + const { projectId } = req.params; + const { email } = req.body; + + const user = await findUserByEmail(email); + + if (!user) { + // Don't reveal if user exists for security + sendResponse(res, { + success: true, + message: "If the email exists, a password reset link has been sent", + data: null, + statusCode: httpStatus.OK, + }); + return; + } + + // Check if user is member of this project + const membership = await checkProjectMembership(user.id, projectId); + if (!membership) { + sendResponse(res, { + success: true, + message: "If the email exists, a password reset link has been sent", + data: null, + statusCode: httpStatus.OK, + }); + return; + } + + const resetToken = createToken( + { + userId: user.id, + email: user.email, + projectId: projectId, + type: "project_customer_reset", + }, + "15m" + ); + + const resetLink = `${client_url}/projects/${projectId}/reset-password?token=${resetToken}`; + await sendResetPassEmail(user.email, resetLink); + + sendResponse(res, { + success: true, + message: "Password reset email sent successfully", + data: null, + statusCode: httpStatus.OK, + }); +}); + +// Reset password +const resetPassword = catchAsync(async (req, res) => { + const { newPassword, token } = req.body; + + if (!token) { + throw new AppError(httpStatus.BAD_REQUEST, "Reset token is required"); + } + + const decoded = verifyToken(token); + + if (decoded.type !== "project_customer_reset") { + throw new AppError(httpStatus.BAD_REQUEST, "Invalid reset token"); + } + + await updateUserPassword(decoded.email, newPassword); + + sendResponse(res, { + success: true, + message: "Password reset successful. Please login with your new password.", + data: null, + statusCode: httpStatus.OK, + }); +}); + +const getProfile = catchAsync(async (req, res) => { + const { userId, projectId } = req.user; + + const { data: user, error } = await supabase + .from("users") + .select("id, name, email, phone, role, is_verified, created_at") + .eq("id", userId) + .single(); + + if (error || !user) { + throw new AppError(httpStatus.NOT_FOUND, "User not found"); + } + + const membership = await checkProjectMembership(userId, projectId); + + sendResponse(res, { + success: true, + message: "Profile retrieved successfully", + data: { + ...user, + projectRole: membership?.role, + }, + statusCode: httpStatus.OK, + }); +}); + +const updateProfile = catchAsync(async (req, res) => { + const { userId } = req.user; + const { name, phone } = req.body; + + const updateData = { + updated_at: new Date().toISOString(), + }; + + if (name) updateData.name = name; + if (phone) updateData.phone = phone; + + const { data: user, error } = await supabase + .from("users") + .update(updateData) + .eq("id", userId) + .select("id, name, email, phone, role, is_verified, created_at") + .single(); + + if (error) { + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "Profile update failed" + ); + } + + sendResponse(res, { + success: true, + message: "Profile updated successfully", + data: user, + statusCode: httpStatus.OK, + }); +}); + +const ProjectAuthController = { + register, + login, + verifyEmail, + resendVerification, + forgotPassword, + resetPassword, + getProfile, + updateProfile, +}; + +module.exports = ProjectAuthController; diff --git a/src/app/modules/projectAuth/projectAuth.routes.js b/src/app/modules/projectAuth/projectAuth.routes.js new file mode 100644 index 0000000..972765a --- /dev/null +++ b/src/app/modules/projectAuth/projectAuth.routes.js @@ -0,0 +1,421 @@ +// modules/ProjectAuth/projectAuth.routes.js - Subdomain-based routes +const router = require("express").Router(); +const validateRequest = require("../../middleware/validateRequest"); +const { projectCustomerAuth } = require("../../middleware/projectAuth"); +const { + detectProjectFromSubdomain, +} = require("../../middleware/subdomainDetection"); +const ProjectAuthController = require("./projectAuth.controller"); +const { + projectCustomerRegisterSchema, + projectCustomerLoginSchema, + projectCustomerUpdateSchema, + emailSchema, + resetPasswordSchema, +} = require("./projectAuth.validation"); + +/** + * @swagger + * tags: + * name: Project Auth + * description: Authentication for ecommerce project customers (subdomain-based) + */ + +/** + * @swagger + * components: + * schemas: + * ProjectUser: + * type: object + * properties: + * id: + * type: string + * format: uuid + * name: + * type: string + * email: + * type: string + * format: email + * phone: + * type: string + * role: + * type: string + * enum: [customer, admin] + * is_verified: + * type: boolean + * created_at: + * type: string + * format: date-time + * Project: + * type: object + * properties: + * id: + * type: string + * format: uuid + * name: + * type: string + * domain: + * type: string + * subdomain: + * type: string + * Error: + * type: object + * properties: + * success: + * type: boolean + * example: false + * message: + * type: string + * statusCode: + * type: integer + */ + +/** + * @swagger + * /project-auth/register: + * post: + * summary: Register customer for current project (detected from subdomain) + * description: | + * Registers a new customer for the project identified by the subdomain/domain. + * Access via subdomain (shop.yourplatform.com) or custom domain (shop.com). + * If user exists globally, adds them to this project. If new user, creates account and adds to project. + * tags: [Project Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - email + * properties: + * name: + * type: string + * example: "John Doe" + * minLength: 1 + * email: + * type: string + * format: email + * example: "john.doe@example.com" + * password: + * type: string + * example: "securePassword123" + * minLength: 6 + * description: Required for new users, optional for existing users + * phone: + * type: string + * example: "+1-555-123-4567" + * responses: + * 201: + * description: Customer registered successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: "Registration successful. Please check your email for verification." + * data: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/ProjectUser' + * project: + * $ref: '#/components/schemas/Project' + * isNewUser: + * type: boolean + * needsVerification: + * type: boolean + * 400: + * description: Validation error or user already registered + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: No active project found for this domain + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + +/** + * @swagger + * /project-auth/login: + * post: + * summary: Login customer to current project (detected from subdomain) + * description: Authenticates customer for the project identified by subdomain/domain + * tags: [Project Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - password + * properties: + * email: + * type: string + * format: email + * example: "john.doe@example.com" + * password: + * type: string + * example: "securePassword123" + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * message: + * type: string + * example: "Login successful" + * data: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/ProjectUser' + * projectRole: + * type: string + * example: "customer" + * accessToken: + * type: string + * description: Project-specific JWT token + * project: + * $ref: '#/components/schemas/Project' + * 400: + * description: Invalid credentials or account issues + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Project not found or user not registered for this project + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + +/** + * @swagger + * /project-auth/verify-email: + * get: + * summary: Verify customer email address + * tags: [Project Auth] + * parameters: + * - in: query + * name: token + * required: true + * schema: + * type: string + * description: Email verification token + * responses: + * 200: + * description: Email verified successfully + * 400: + * description: Invalid or expired token + */ + +/** + * @swagger + * /project-auth/resend-verification: + * post: + * summary: Resend email verification + * tags: [Project Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * example: "john.doe@example.com" + * responses: + * 200: + * description: Verification email sent + * 400: + * description: Email already verified or validation error + * 404: + * description: User not found or not registered for this project + */ + +/** + * @swagger + * /project-auth/forgot-password: + * post: + * summary: Request password reset + * tags: [Project Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * properties: + * email: + * type: string + * format: email + * example: "john.doe@example.com" + * responses: + * 200: + * description: Password reset email sent (if email exists) + * 400: + * description: Validation error + */ + +/** + * @swagger + * /project-auth/reset-password: + * post: + * summary: Reset customer password + * tags: [Project Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - token + * - newPassword + * properties: + * token: + * type: string + * description: Password reset token + * newPassword: + * type: string + * minLength: 6 + * example: "newSecurePassword123" + * responses: + * 200: + * description: Password reset successful + * 400: + * description: Invalid token or validation error + */ + +/** + * @swagger + * /project-auth/profile: + * get: + * summary: Get customer profile + * description: Get authenticated customer's profile for current project + * tags: [Project Auth] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * data: + * allOf: + * - $ref: '#/components/schemas/ProjectUser' + * - type: object + * properties: + * projectRole: + * type: string + * example: "customer" + * 401: + * description: Unauthorized + * 404: + * description: User not found + * patch: + * summary: Update customer profile + * tags: [Project Auth] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "John Updated" + * phone: + * type: string + * example: "+1-555-987-6543" + * responses: + * 200: + * description: Profile updated successfully + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ + +router.use(detectProjectFromSubdomain()); + +router.post( + "/register", + validateRequest(projectCustomerRegisterSchema), + ProjectAuthController.register +); + +router.post( + "/login", + validateRequest(projectCustomerLoginSchema), + ProjectAuthController.login +); + +router.get("/verify-email", ProjectAuthController.verifyEmail); + +router.post( + "/resend-verification", + validateRequest(emailSchema), + ProjectAuthController.resendVerification +); + +router.post( + "/forgot-password", + validateRequest(emailSchema), + ProjectAuthController.forgotPassword +); + +router.post( + "/reset-password", + validateRequest(resetPasswordSchema), + ProjectAuthController.resetPassword +); + +// Protected routes +router.get("/profile", projectCustomerAuth(), ProjectAuthController.getProfile); + +router.patch( + "/profile", + projectCustomerAuth(), + validateRequest(projectCustomerUpdateSchema), + ProjectAuthController.updateProfile +); + +module.exports = router; diff --git a/src/app/modules/projectAuth/projectAuth.service.js b/src/app/modules/projectAuth/projectAuth.service.js new file mode 100644 index 0000000..6b70970 --- /dev/null +++ b/src/app/modules/projectAuth/projectAuth.service.js @@ -0,0 +1,315 @@ +const httpStatus = require("http-status"); +const AppError = require("../../errors/AppError"); +const bcrypt = require("bcrypt"); +const { createToken } = require("../../utils/jwt"); +const supabase = require("../../config/supabaseClient"); + +const registerProjectCustomer = async (projectId, payload) => { + const { name, email, password, phone } = payload; + + const { data: project, error: projectError } = await supabase + .from("projects") + .select("*") + .eq("id", projectId) + .eq("is_published", true) + .single(); + + if (projectError || !project) { + throw new AppError( + httpStatus.NOT_FOUND, + "Project not found or not available" + ); + } + + // Check if user already exists globally + const { data: existingUser } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); + + let user; + let isNewUser = false; + + if (existingUser) { + const { data: existingMember } = await supabase + .from("project_members") + .select("*") + .eq("project_id", projectId) + .eq("user_id", existingUser.id) + .single(); + + if (existingMember) { + throw new AppError( + httpStatus.BAD_REQUEST, + "You are already registered for this project" + ); + } + + if (password && existingUser.password_hash) { + throw new AppError( + httpStatus.BAD_REQUEST, + "You already have an account. Please login instead." + ); + } + + if (password && !existingUser.password_hash) { + const hashedPassword = await bcrypt.hash(password, 10); + const { error: updateError } = await supabase + .from("users") + .update({ + password_hash: hashedPassword, + name: name || existingUser.name, + phone: phone || existingUser.phone, + updated_at: new Date().toISOString(), + }) + .eq("id", existingUser.id); + + if (updateError) { + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "Failed to update user" + ); + } + + // Fetch updated user + const { data: updatedUser } = await supabase + .from("users") + .select("*") + .eq("id", existingUser.id) + .single(); + + user = updatedUser; + } else { + user = existingUser; + } + } else { + if (!password) { + throw new AppError( + httpStatus.BAD_REQUEST, + "Password is required for new users" + ); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const { data: newUser, error: userError } = await supabase + .from("users") + .insert({ + name, + email, + phone, + password_hash: hashedPassword, + role: "customer", + is_verified: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .select() + .single(); + + if (userError) { + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "User creation failed" + ); + } + + user = newUser; + isNewUser = true; + } + + const { error: memberError } = await supabase.from("project_members").insert({ + project_id: projectId, + user_id: user.id, + role: "customer", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + if (memberError) { + // If this was a new user and project_member creation failed, clean up + if (isNewUser) { + await supabase.from("users").delete().eq("id", user.id); + } + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "Failed to add user to project" + ); + } + + return { user, project, isNewUser }; +}; + +// Login customer to a specific project +const loginProjectCustomer = async (projectId, payload) => { + const { email, password } = payload; + + // Check if project exists and is active + const { data: project, error: projectError } = await supabase + .from("projects") + .select("*") + .eq("id", projectId) + .eq("is_published", true) + .single(); + + if (projectError || !project) { + throw new AppError( + httpStatus.NOT_FOUND, + "Project not found or not available" + ); + } + + // Find user in users table + const { data: user, error: userError } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); + + if (userError || !user) { + throw new AppError(httpStatus.BAD_REQUEST, "Invalid credentials"); + } + + // Check if user is member of this project + const { data: membership, error: memberError } = await supabase + .from("project_members") + .select("*") + .eq("project_id", projectId) + .eq("user_id", user.id) + .single(); + + if (memberError || !membership) { + throw new AppError( + httpStatus.BAD_REQUEST, + "You are not registered for this project" + ); + } + + if (!user.is_verified) { + throw new AppError( + httpStatus.BAD_REQUEST, + "Please verify your email before logging in" + ); + } + + if (user.status !== "active") { + throw new AppError(httpStatus.FORBIDDEN, "Account is not active"); + } + + // Verify password + const isPasswordValid = await bcrypt.compare(password, user.password_hash); + if (!isPasswordValid) { + throw new AppError(httpStatus.BAD_REQUEST, "Invalid credentials"); + } + + // Create project-specific token + const token = createToken({ + userId: user.id, + email: user.email, + projectId: projectId, + projectRole: membership.role, + type: "project_member", + }); + + // Update last login + await supabase + .from("users") + .update({ + updated_at: new Date().toISOString(), + }) + .eq("id", user.id); + + const { password_hash, ...userData } = user; + + return { + user: userData, + projectRole: membership.role, + accessToken: token, + project: { + id: project.id, + name: project.name, + domain: project.domain, + subdomain: project.subdomain, + }, + }; +}; + +// Find user by email globally +const findUserByEmail = async (email) => { + const { data: user, error } = await supabase + .from("users") + .select("*") + .eq("email", email) + .single(); + + if (error || !user) { + return null; + } + + return user; +}; + +// Update user password +const updateUserPassword = async (email, newPassword) => { + const hashedPassword = await bcrypt.hash(newPassword, 10); + + const { error } = await supabase + .from("users") + .update({ + password_hash: hashedPassword, + updated_at: new Date().toISOString(), + }) + .eq("email", email); + + if (error) { + throw new AppError( + httpStatus.INTERNAL_SERVER_ERROR, + "Password update failed" + ); + } + + return true; +}; + +// Verify user email +const verifyUserEmail = async (userId) => { + const { error } = await supabase + .from("users") + .update({ + is_verified: true, + updated_at: new Date().toISOString(), + }) + .eq("id", userId); + + if (error) { + throw new AppError(httpStatus.BAD_REQUEST, "Email verification failed"); + } + + return true; +}; + +// Check if user is member of project +const checkProjectMembership = async (userId, projectId) => { + const { data: membership, error } = await supabase + .from("project_members") + .select("*") + .eq("project_id", projectId) + .eq("user_id", userId) + .single(); + + if (error || !membership) { + return null; + } + + return membership; +}; + +module.exports = { + registerProjectCustomer, + loginProjectCustomer, + findUserByEmail, + updateUserPassword, + verifyUserEmail, + checkProjectMembership, +}; diff --git a/src/app/modules/projectAuth/projectAuth.validation.js b/src/app/modules/projectAuth/projectAuth.validation.js new file mode 100644 index 0000000..1a9a76f --- /dev/null +++ b/src/app/modules/projectAuth/projectAuth.validation.js @@ -0,0 +1,38 @@ +const z = require("zod"); + +const projectCustomerRegisterSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Valid email address required"), + password: z + .string() + .min(6, "Password must be at least 6 characters") + .optional(), // Optional for existing users + phone: z.string().optional(), +}); + +const projectCustomerLoginSchema = z.object({ + email: z.string().email("Valid email address required"), + password: z.string().min(1, "Password is required"), +}); + +const projectCustomerUpdateSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").optional(), + phone: z.string().optional(), +}); + +const emailSchema = z.object({ + email: z.string().email("Valid email address required"), +}); + +const resetPasswordSchema = z.object({ + token: z.string().min(1, "Reset token is required"), + newPassword: z.string().min(6, "Password must be at least 6 characters"), +}); + +module.exports = { + projectCustomerRegisterSchema, + projectCustomerLoginSchema, + projectCustomerUpdateSchema, + emailSchema, + resetPasswordSchema, +}; diff --git a/src/app/routes/index.js b/src/app/routes/index.js index eb1bd7a..9e2e8dd 100644 --- a/src/app/routes/index.js +++ b/src/app/routes/index.js @@ -1,6 +1,7 @@ const router = require("express").Router(); const authRoutes = require("../modules/Auth/auth.routes"); const userRoutes = require("../modules/User/user.routes"); +const projectAuth = require("../modules/projectAuth/projectAuth.routes"); const { path } = require("../../app"); const moduleRoutes = [ { @@ -11,6 +12,10 @@ const moduleRoutes = [ path: "/users", route: userRoutes, }, + { + path: "/project-auth", + route: projectAuth, + }, ]; moduleRoutes.forEach((route) => router.use(route.path, route.route)); diff --git a/src/app/swagger/swaggerConfig.js b/src/app/swagger/swaggerConfig.js index 152bc94..2345951 100644 --- a/src/app/swagger/swaggerConfig.js +++ b/src/app/swagger/swaggerConfig.js @@ -4,12 +4,12 @@ const options = { definition: { openapi: "3.0.0", info: { - title: "Learnup Bangladesh API", + title: "Website Builder API", version: "1.0.0", - description: "API documentation for Learnup Bangladesh backend", + description: "API documentation for Website Builder backend", contact: { - name: "Learnup Bangladesh Dev Team", - email: "learnupbangladesh@gmail.com", + name: "Website Builder Team", + email: "websitebuilder@gmail.com", }, }, servers: [