From a1583ebb298e7f8d31bd16edb25dde77e33e4578 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:14:47 +0200 Subject: [PATCH 01/28] fix(auth-google): add log to callback --- src/controllers/auth.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index b12e238..380abe0 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -154,6 +154,7 @@ async function loginWithGoogleCallback( res: Response, next: NextFunction ) { + console.log("loginWithGoogleCallback"); passport.authenticate("google", { session: false }, (err, data) => { if (err || !data) { return res.redirect( From 40c221c4f6139cb43ac0fbb5f5089a792e1fb75d Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:17:56 +0200 Subject: [PATCH 02/28] fix(auth-google): add log to callback --- src/controllers/auth.controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 380abe0..cc4b5d4 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -189,6 +189,8 @@ async function loginWithGoogleCallback( queryParams += `&redirectUrl=${stateData.redirectUrl}`; } + console.log(`${process.env.FRONT_URL}/auth-google-success${queryParams}`); + return res.redirect( `${process.env.FRONT_URL}/auth-google-success${queryParams}` ); From c60151420b5e6c542341556488cdae52744cb766 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:21:14 +0200 Subject: [PATCH 03/28] fix(auth-google): add log to callback --- src/config/passport.ts | 1 + src/controllers/auth.controller.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/config/passport.ts b/src/config/passport.ts index 04d8e45..098a221 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -101,6 +101,7 @@ const googleStrategy = new GoogleStrategy( profile: any, done: any ) { + console.log("GoogleStrategy"); try { let stateData = null; diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index cc4b5d4..23adfd4 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -156,6 +156,7 @@ async function loginWithGoogleCallback( ) { console.log("loginWithGoogleCallback"); passport.authenticate("google", { session: false }, (err, data) => { + console.log("passport authenticate google", err, data); if (err || !data) { return res.redirect( `${process.env.FRONT_URL}/signin?error=auth_google_failed` From 03d3b3a1e82c129b4207ba68a5f8c93a18fe5bf8 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:34:15 +0200 Subject: [PATCH 04/28] fix(auth-google): fix issue in findOrCreateUser --- src/controllers/auth.controller.ts | 5 +++-- src/services/users.services.ts | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 23adfd4..ab40e72 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -154,9 +154,10 @@ async function loginWithGoogleCallback( res: Response, next: NextFunction ) { - console.log("loginWithGoogleCallback"); + console.log("loginWithGoogleCallback - Start"); passport.authenticate("google", { session: false }, (err, data) => { - console.log("passport authenticate google", err, data); + console.log("passport authenticate google - Error:", err); + console.log("passport authenticate google - Data:", data ? "received" : "null"); if (err || !data) { return res.redirect( `${process.env.FRONT_URL}/signin?error=auth_google_failed` diff --git a/src/services/users.services.ts b/src/services/users.services.ts index d814f16..5fd1823 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -29,23 +29,25 @@ const findOrCreateUser = async (userData: Partial) => { let refreshToken: string; if (!user) { - accessToken = generateAccessToken({ - _id: userInfo._id, - email: userInfo.email, - }); - refreshToken = generateRefreshToken(userInfo); - const user = new User({ email: userData.email, password: null, googleId: userData.googleId, profile: userData.profile || {}, isVerified: true, - refreshToken, }); await user.save(); userInfo = await getUserInfo(user); + + accessToken = generateAccessToken({ + _id: userInfo._id, + email: userInfo.email, + }); + refreshToken = generateRefreshToken(userInfo); + + user.set({ refreshToken }); + await user.save(); } else { userInfo = await getUserInfo(user); @@ -55,7 +57,7 @@ const findOrCreateUser = async (userData: Partial) => { }); refreshToken = generateRefreshToken(userInfo); - await user.set({ refreshToken, googleId: userData.googleId }); + user.set({ refreshToken, googleId: userData.googleId }); await user.save(); } From cdcd122b8c8df093a35bd4729fe60ad77bfd3607 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:41:34 +0200 Subject: [PATCH 05/28] fix(auth-google): add log to callback --- src/config/passport.ts | 17 ++++++++++++++++- src/controllers/auth.controller.ts | 8 ++++++++ src/services/users.services.ts | 6 +++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/config/passport.ts b/src/config/passport.ts index 098a221..af36949 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -87,6 +87,14 @@ const jwtLogin = new JwtStrategy( const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID as string; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET as string; +console.log("Google OAuth Config:", { + hasClientId: !!GOOGLE_CLIENT_ID, + clientIdLength: GOOGLE_CLIENT_ID?.length || 0, + hasClientSecret: !!GOOGLE_CLIENT_SECRET, + secretLength: GOOGLE_CLIENT_SECRET?.length || 0, + callbackURL: `${process.env.API_URL}/auth/google/callback` +}); + const googleStrategy = new GoogleStrategy( { clientID: GOOGLE_CLIENT_ID, @@ -101,7 +109,11 @@ const googleStrategy = new GoogleStrategy( profile: any, done: any ) { - console.log("GoogleStrategy"); + console.log("GoogleStrategy - START", { + profileId: profile?.id, + email: profile?.emails?.[0]?.value, + hasAccessToken: !!accessToken + }); try { let stateData = null; @@ -139,6 +151,7 @@ const googleStrategy = new GoogleStrategy( } } + console.log("GoogleStrategy - Calling findOrCreateUser"); const user = await findOrCreateUser({ email: profile.emails[0].value, googleId: profile.id, @@ -148,6 +161,8 @@ const googleStrategy = new GoogleStrategy( }, }); + console.log("GoogleStrategy - findOrCreateUser result:", user ? "success" : "failed"); + // Pass both user and state data to the callback return done(null, { user, stateData }); } catch (error) { diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index ab40e72..0477c1d 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -155,7 +155,15 @@ async function loginWithGoogleCallback( next: NextFunction ) { console.log("loginWithGoogleCallback - Start"); + + // Add timeout to detect if passport.authenticate never calls back + const timeoutId = setTimeout(() => { + console.error("TIMEOUT: passport.authenticate callback never called after 30 seconds"); + }, 30000); + passport.authenticate("google", { session: false }, (err, data) => { + clearTimeout(timeoutId); + console.log("passport authenticate google - CALLBACK REACHED"); console.log("passport authenticate google - Error:", err); console.log("passport authenticate google - Data:", data ? "received" : "null"); if (err || !data) { diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 5fd1823..03bb425 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -22,8 +22,11 @@ const getUserInfo = async (user: IUser) => { // Use for Signin/Signup with Google 0Auth2 const findOrCreateUser = async (userData: Partial) => { + console.log("findOrCreateUser - START", { email: userData.email, googleId: userData.googleId }); try { + console.log("findOrCreateUser - Checking database connection..."); const user = await User.findOne({ email: userData.email }); + console.log("findOrCreateUser - Database query completed", { userFound: !!user }); let userInfo: any = {}; let accessToken: string; let refreshToken: string; @@ -61,13 +64,14 @@ const findOrCreateUser = async (userData: Partial) => { await user.save(); } + console.log("findOrCreateUser - SUCCESS", { userId: userInfo._id }); return { user: userInfo, refreshToken, token: accessToken, }; } catch (err) { - console.log(err); + console.error("findOrCreateUser - ERROR:", err); return err; } }; From b8187d3a84e599f87e8b1e002e669c5afcb2a1dd Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 17:48:19 +0200 Subject: [PATCH 06/28] feat(rate-limiter): set try proxy to 1 on the express app --- src/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server.ts b/src/server.ts index 9e04e77..39a4010 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,8 @@ app.use( }) ); +app.set("trust proxy", 1); + app.use(express.json()); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); From e4d67e0fd90053eaab8810ff1cf238d5c4123c68 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 20 Aug 2025 18:07:54 +0200 Subject: [PATCH 07/28] fix(auth-google): remove logs --- src/config/passport.ts | 15 --------------- src/controllers/auth.controller.ts | 11 ----------- src/services/users.services.ts | 6 +----- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/src/config/passport.ts b/src/config/passport.ts index af36949..cd76d6d 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -87,13 +87,6 @@ const jwtLogin = new JwtStrategy( const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID as string; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET as string; -console.log("Google OAuth Config:", { - hasClientId: !!GOOGLE_CLIENT_ID, - clientIdLength: GOOGLE_CLIENT_ID?.length || 0, - hasClientSecret: !!GOOGLE_CLIENT_SECRET, - secretLength: GOOGLE_CLIENT_SECRET?.length || 0, - callbackURL: `${process.env.API_URL}/auth/google/callback` -}); const googleStrategy = new GoogleStrategy( { @@ -109,11 +102,6 @@ const googleStrategy = new GoogleStrategy( profile: any, done: any ) { - console.log("GoogleStrategy - START", { - profileId: profile?.id, - email: profile?.emails?.[0]?.value, - hasAccessToken: !!accessToken - }); try { let stateData = null; @@ -151,7 +139,6 @@ const googleStrategy = new GoogleStrategy( } } - console.log("GoogleStrategy - Calling findOrCreateUser"); const user = await findOrCreateUser({ email: profile.emails[0].value, googleId: profile.id, @@ -161,8 +148,6 @@ const googleStrategy = new GoogleStrategy( }, }); - console.log("GoogleStrategy - findOrCreateUser result:", user ? "success" : "failed"); - // Pass both user and state data to the callback return done(null, { user, stateData }); } catch (error) { diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 0477c1d..9de3677 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -154,18 +154,7 @@ async function loginWithGoogleCallback( res: Response, next: NextFunction ) { - console.log("loginWithGoogleCallback - Start"); - - // Add timeout to detect if passport.authenticate never calls back - const timeoutId = setTimeout(() => { - console.error("TIMEOUT: passport.authenticate callback never called after 30 seconds"); - }, 30000); - passport.authenticate("google", { session: false }, (err, data) => { - clearTimeout(timeoutId); - console.log("passport authenticate google - CALLBACK REACHED"); - console.log("passport authenticate google - Error:", err); - console.log("passport authenticate google - Data:", data ? "received" : "null"); if (err || !data) { return res.redirect( `${process.env.FRONT_URL}/signin?error=auth_google_failed` diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 03bb425..5fd1823 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -22,11 +22,8 @@ const getUserInfo = async (user: IUser) => { // Use for Signin/Signup with Google 0Auth2 const findOrCreateUser = async (userData: Partial) => { - console.log("findOrCreateUser - START", { email: userData.email, googleId: userData.googleId }); try { - console.log("findOrCreateUser - Checking database connection..."); const user = await User.findOne({ email: userData.email }); - console.log("findOrCreateUser - Database query completed", { userFound: !!user }); let userInfo: any = {}; let accessToken: string; let refreshToken: string; @@ -64,14 +61,13 @@ const findOrCreateUser = async (userData: Partial) => { await user.save(); } - console.log("findOrCreateUser - SUCCESS", { userId: userInfo._id }); return { user: userInfo, refreshToken, token: accessToken, }; } catch (err) { - console.error("findOrCreateUser - ERROR:", err); + console.log(err); return err; } }; From e4f94ec8f563609a600d75c42cd2294c7f910992 Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 21 Aug 2025 12:58:15 +0200 Subject: [PATCH 08/28] fix(sendDailyEmailToUsers): add logs --- src/services/users.services.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 5fd1823..6bbb924 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -113,6 +113,10 @@ const sendDailyEmailToUsers = async () => { {} ); + console.log("sendDailyEmailToUsers"); + console.log(users); + console.log(JSON.stringify(groupedTasks, null, 2)); + const emails = Object.entries(groupedTasks).map(([email, tasks]) => { const username = tasks[0].user.profile.firstName + " " + tasks[0].user.profile.lastName; From ef3802c30248d657eea198dcbbb2c6745a65fc8a Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 21 Aug 2025 14:16:08 +0200 Subject: [PATCH 09/28] feat(send daily reminders): remove startOfDay and endOfDay function --- src/services/users.services.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 6bbb924..76fc7be 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -1,4 +1,3 @@ -import { startOfDay, endOfDay } from "date-fns"; import { Resend } from "resend"; import dotenv from "dotenv"; import dotEnvConfig from "~/config/dot-env"; @@ -78,12 +77,18 @@ const sendDailyEmailToUsers = async () => { "_id email" ); + const startOfDay = new Date(today); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(today); + endOfDay.setHours(23, 59, 59, 999); + const tasks = await Task.find({ user: { $in: users.map((user: any) => user._id) }, completed: false, dueDate: { - $gte: startOfDay(today), - $lte: endOfDay(today), + $gte: startOfDay, + $lte: endOfDay, }, }).populate([ { From 17abf5b113fd540952c0bf625d723527189ac3d5 Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 21 Aug 2025 16:42:22 +0200 Subject: [PATCH 10/28] feat(sendDailyEmailToUsers): try to use timezone --- src/controllers/auth.controller.ts | 2 ++ src/models/user.ts | 5 +++++ src/schemas/user.schema.ts | 2 ++ src/services/users.services.ts | 36 +++++++++++++++++++++++++----- src/types/users.ts | 1 + 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 9de3677..993434b 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -33,6 +33,7 @@ async function register(req: Request, res: Response) { const email = req.body.email; const password = req.body.password; const profile = req.body.profile; + const timezone = req.body.timezone; const existingUser = await User.findOne({ email }); if (existingUser) { @@ -51,6 +52,7 @@ async function register(req: Request, res: Response) { verificationToken, verificationTokenExpires, profile: profile || {}, + timezone, }); await user.save(); diff --git a/src/models/user.ts b/src/models/user.ts index 3f00236..345d318 100755 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -26,6 +26,11 @@ const UserSchema = new Schema( required: true, default: "Member", }, + timezone: { + type: String, + required: true, + default: "Europe/Paris", + }, googleId: { type: Number, default: null, diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 009067c..1a1e118 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -9,6 +9,7 @@ const UserSchema = z.object({ example: "67c5c2e9656ca8c7f95f7d52", }), role: z.string().openapi({ example: "Member" }), + timezone: z.string().openapi({ example: "Europe/Paris" }), googleId: z.number().nullable().openapi({ example: 78972475051234701234 }), isVerified: z.boolean().openapi({ example: false }), dailyEmailReminder: z.boolean().openapi({ example: false }), @@ -52,6 +53,7 @@ const RegisterUserSchema = UserSchema.pick({ email: true, password: true, profile: true, + timezone: true, }); const LoginUserSchema = UserSchema.pick({ email: true, password: true }); diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 76fc7be..34bdaad 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -72,16 +72,42 @@ const findOrCreateUser = async (userData: Partial) => { }; const sendDailyEmailToUsers = async () => { - const today = new Date(); const users = await User.find({ dailyEmailReminder: true }).select( - "_id email" + "_id email timezone" ); - const startOfDay = new Date(today); - startOfDay.setHours(0, 0, 0, 0); + // const timezones = users.map((user: any) => user.timezone); + + // const dates = timezones.map((timezone: any) => { + // const date = new Date(); + // return new Date(date.toLocaleDateString("sv-SE", { timeZone: timezone })); + // }); + + // console.log(dates); + + // Timezone actuelle (celle de votre navigateur/système) + const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const endOfDay = new Date(today); + // Obtenir l'heure dans les deux timezones + const currentTime = new Date( + new Date().toLocaleString("en-US", { + timeZone: currentTimezone, + }) + ); + const targetTime = new Date( + new Date().toLocaleString("en-US", { + timeZone: "Europe/Paris", + }) + ); + + // Calculer la différence en heures + const diffMs = currentTime.getTime() - targetTime.getTime(); + let startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + startOfDay = new Date(startOfDay.getTime() + diffMs); + let endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); + endOfDay = new Date(endOfDay.getTime() + diffMs); const tasks = await Task.find({ user: { $in: users.map((user: any) => user._id) }, diff --git a/src/types/users.ts b/src/types/users.ts index a05f1f4..c22fbb4 100755 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -10,6 +10,7 @@ export interface IUser extends Document { email: string; profile: UserProfile; role: string; + timezone: string; googleId: number | null; isVerified: boolean; dailyEmailReminder: boolean; From 1ad39c420fec8541f1a368558e18876d42ea3ce1 Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 21 Aug 2025 17:41:26 +0200 Subject: [PATCH 11/28] feat(sendDailyEmailToUsers): refactor to handle user timezone --- src/services/users.services.ts | 109 ++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 34bdaad..bec0d15 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -76,62 +76,73 @@ const sendDailyEmailToUsers = async () => { "_id email timezone" ); - // const timezones = users.map((user: any) => user.timezone); + const timezones = [...new Set(users.map((user) => user.timezone))]; - // const dates = timezones.map((timezone: any) => { - // const date = new Date(); - // return new Date(date.toLocaleDateString("sv-SE", { timeZone: timezone })); - // }); - - // console.log(dates); - - // Timezone actuelle (celle de votre navigateur/système) const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - // Obtenir l'heure dans les deux timezones - const currentTime = new Date( - new Date().toLocaleString("en-US", { - timeZone: currentTimezone, - }) - ); - const targetTime = new Date( - new Date().toLocaleString("en-US", { - timeZone: "Europe/Paris", - }) - ); + const getTasksForTimezone = async ( + timezone: string + ): Promise => { + const currentTime = new Date( + new Date().toLocaleString("en-US", { + timeZone: currentTimezone, + }) + ); + const targetTime = new Date( + new Date().toLocaleString("en-US", { + timeZone: timezone, + }) + ); + + const diffMs = currentTime.getTime() - targetTime.getTime(); + + const now = new Date(); + let startOfDay = new Date( + now.toLocaleString("sv-SE", { timeZone: timezone }) + ); + + const timeoffset = diffMs; + startOfDay.setHours(0, 0, 0, 0); + startOfDay = new Date(startOfDay.getTime() + timeoffset); + + let endOfDay = new Date( + now.toLocaleString("sv-SE", { timeZone: timezone }) + ); + endOfDay.setHours(23, 59, 59, 999); + endOfDay = new Date(endOfDay.getTime() + timeoffset); + + const tasks = await Task.find({ + user: { $in: users.map((user: any) => user._id) }, + completed: false, + dueDate: { + $gte: startOfDay, + $lte: endOfDay, + }, + }).populate([ + { + path: "tags", + select: "_id label color", + }, + { + path: "user", + select: "_id email profile", + }, + ]); + + return tasks; + }; - // Calculer la différence en heures - const diffMs = currentTime.getTime() - targetTime.getTime(); - let startOfDay = new Date(); - startOfDay.setHours(0, 0, 0, 0); - startOfDay = new Date(startOfDay.getTime() + diffMs); - let endOfDay = new Date(); - endOfDay.setHours(23, 59, 59, 999); - endOfDay = new Date(endOfDay.getTime() + diffMs); - - const tasks = await Task.find({ - user: { $in: users.map((user: any) => user._id) }, - completed: false, - dueDate: { - $gte: startOfDay, - $lte: endOfDay, - }, - }).populate([ - { - path: "tags", - select: "_id label color", - }, - { - path: "user", - select: "_id email profile", - }, - ]); + const taskPromises = timezones.map((timezone) => + getTasksForTimezone(timezone) + ); const idToEmails = users.reduce((acc: any, user: any) => { acc[user._id] = user.email; return acc; }, {}); + const tasks = (await Promise.all(taskPromises)).flat(); + const groupedTasks: { [key: string]: TaskDocument[] } = tasks.reduce( (acc: any, task: TaskDocument) => { const email = idToEmails[task.user._id.toString()]; @@ -144,15 +155,11 @@ const sendDailyEmailToUsers = async () => { {} ); - console.log("sendDailyEmailToUsers"); - console.log(users); - console.log(JSON.stringify(groupedTasks, null, 2)); - const emails = Object.entries(groupedTasks).map(([email, tasks]) => { const username = tasks[0].user.profile.firstName + " " + tasks[0].user.profile.lastName; - const subject = `${tasks.length} tâche${tasks.length > 0 ? "s" : ""} prévue${tasks.length > 0 ? "s" : ""} aujourd'hui`; + const subject = `${tasks.length} tâche${tasks.length > 1 ? "s" : ""} prévue${tasks.length > 1 ? "s" : ""} aujourd'hui`; return { from: `${process.env.PROJECT_NAME} <${process.env.NOREPLY}>`, From 50da5724e4a57361d4360c19b1572bd6eb646ddf Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 21 Aug 2025 18:00:22 +0200 Subject: [PATCH 12/28] feat(auth google): add support for set timezone when create user --- src/config/passport.ts | 2 +- src/controllers/auth.controller.ts | 9 ++++++++- src/services/users.services.ts | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config/passport.ts b/src/config/passport.ts index cd76d6d..0af39d3 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -87,7 +87,6 @@ const jwtLogin = new JwtStrategy( const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID as string; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET as string; - const googleStrategy = new GoogleStrategy( { clientID: GOOGLE_CLIENT_ID, @@ -146,6 +145,7 @@ const googleStrategy = new GoogleStrategy( firstName: profile.name?.familyName || "Non renseigné", lastName: profile.name?.givenName || "Non renseigné", }, + timezone: stateData?.timezone, }); // Pass both user and state data to the callback diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 993434b..3659863 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -120,15 +120,22 @@ async function loginWithGoogle( ) { try { const redirectUrl = (req.query.redirectUrl || "") as string; + const timezone = (req.query.timezone || "") as string; + + console.log(timezone); const stateObject = { - data: {} as { redirectUrl?: string }, + data: {} as { redirectUrl?: string; timezone?: string }, }; if (["profile"].includes(redirectUrl)) { stateObject.data.redirectUrl = redirectUrl; } + if (timezone) { + stateObject.data.timezone = timezone; + } + // Encode state as base64url (URL-safe) const stateJson = JSON.stringify(stateObject); const state = Buffer.from(stateJson).toString("base64url"); diff --git a/src/services/users.services.ts b/src/services/users.services.ts index bec0d15..7d40c2a 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -33,6 +33,7 @@ const findOrCreateUser = async (userData: Partial) => { password: null, googleId: userData.googleId, profile: userData.profile || {}, + timezone: userData.timezone, isVerified: true, }); From 4813369bf92e7523aa286e916dc9f38421ce8b99 Mon Sep 17 00:00:00 2001 From: HRulier Date: Mon, 1 Sep 2025 17:29:00 +0200 Subject: [PATCH 13/28] feat: add auth with slack endpoint --- package-lock.json | 9 +++ package.json | 1 + src/config/passport.ts | 78 +++++++++++++++++++++++++ src/controllers/auth.controller.ts | 86 ++++++++++++++++++++++++++-- src/routes/auth.routes.ts | 5 ++ src/schemas/user.schema.ts | 1 + src/services/users.services.ts | 14 ++++- src/types/auth.ts | 6 ++ src/types/passport-slack-oauth2.d.ts | 54 +++++++++++++++++ src/types/users.ts | 1 + 10 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 src/types/passport-slack-oauth2.d.ts diff --git a/package-lock.json b/package-lock.json index 8f041eb..a7e700c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-slack-oauth2": "^1.2.0", "react": "19.0.0", "react-dom": "19.0.0", "resend": "^4.1.2", @@ -8419,6 +8420,14 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-slack-oauth2": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/passport-slack-oauth2/-/passport-slack-oauth2-1.2.0.tgz", + "integrity": "sha512-SeQl8uPoi4ajhzgIvwQM7gW/6yPrKH0hPFjxcP/426SOZ0M9ZNDOfSa32q3NTw7KcwYOTjyWX/2xdJndQE7Rkg==", + "dependencies": { + "passport-oauth2": "^1.7.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/package.json b/package.json index 4b971e4..55a22f2 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-slack-oauth2": "^1.2.0", "react": "19.0.0", "react-dom": "19.0.0", "resend": "^4.1.2", diff --git a/src/config/passport.ts b/src/config/passport.ts index 0af39d3..1adfbc3 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -3,6 +3,7 @@ import User from "~/models/user"; import passportJWT from "passport-jwt"; import passportLocal from "passport-local"; import passportGoogleOAuth20 from "passport-google-oauth20"; +import passportSlackOAuth20 from "passport-slack-oauth2"; import dotenv from "dotenv"; import dotEnvConfig from "./dot-env"; @@ -14,6 +15,7 @@ dotenv.config(dotEnvConfig); const { Strategy: LocalStrategy } = passportLocal; const { Strategy: JwtStrategy, ExtractJwt } = passportJWT; const { Strategy: GoogleStrategy } = passportGoogleOAuth20; +const { Strategy: SlackStrategy } = passportSlackOAuth20; // Setting username field to email rather than username const localOptions = { @@ -157,6 +159,82 @@ const googleStrategy = new GoogleStrategy( } ); +// Slack 0Auth2 + +const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID as string; +const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET as string; + +const slackStrategy = new SlackStrategy( + { + clientID: SLACK_CLIENT_ID, + clientSecret: SLACK_CLIENT_SECRET, + // callbackURL: `https://localhost:1700/api/auth/slack/callback`, + callbackURL: `https://9eafcadf750f.ngrok-free.app/api/auth/slack/callback`, + passReqToCallback: true, + scope: [ + // "users.profile:read", + // "users:read", + // "users:read.email", + "identity.basic", + ], + }, + async ( + req: any, + accessToken: any, + refreshToken: any, + profile: any, + done: any + ) => { + let stateData = null; + + // Extract and decode state parameter if present + if (req.query.state) { + try { + // Validate state parameter as string because it's base64url + if (typeof req.query.state !== "string") { + throw new Error("Invalid state parameter format"); + } + + const decodedState = Buffer.from( + req.query.state, + "base64url" + ).toString(); + + const stateObject = JSON.parse(decodedState); + + if (!stateObject.data || typeof stateObject.data !== "object") { + throw new Error("Missing or invalid data in state"); + } + + stateData = stateObject.data; + } catch (stateError: any) { + console.error("OAuth state validation failed:", { + error: stateError.message, + stateLength: req.query.state?.length || 0, + userAgent: req.get("User-Agent"), + ip: req.ip || req.connection.remoteAddress, + }); + + // For security reasons, don't expose detailed error messages + // Continue without state data - don't fail the authentication + stateData = null; + } + } + const user = await findOrCreateUser({ + email: profile.user.email, + slackId: profile.user.id, + profile: { + firstName: profile.user.name || "Non renseigné", + lastName: "Non renseigné", + }, + timezone: stateData?.timezone, + }); + + done(null, { user }); + } +); + passport.use(jwtLogin); passport.use(localLogin); passport.use(googleStrategy); +passport.use(slackStrategy); diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 3659863..1b732b9 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -122,8 +122,6 @@ async function loginWithGoogle( const redirectUrl = (req.query.redirectUrl || "") as string; const timezone = (req.query.timezone || "") as string; - console.log(timezone); - const stateObject = { data: {} as { redirectUrl?: string; timezone?: string }, }; @@ -197,11 +195,85 @@ async function loginWithGoogleCallback( queryParams += `&redirectUrl=${stateData.redirectUrl}`; } - console.log(`${process.env.FRONT_URL}/auth-google-success${queryParams}`); + console.log(`${process.env.FRONT_URL}/auth-success${queryParams}`); - return res.redirect( - `${process.env.FRONT_URL}/auth-google-success${queryParams}` - ); + return res.redirect(`${process.env.FRONT_URL}/auth-success${queryParams}`); + })(req, res, next); +} + +async function loginWithSlack(req: Request, res: Response, next: NextFunction) { + try { + const redirectUrl = (req.query.redirectUrl || "") as string; + const timezone = (req.query.timezone || "") as string; + + const stateObject = { + data: {} as { redirectUrl?: string; timezone?: string }, + }; + + if (["profile"].includes(redirectUrl)) { + stateObject.data.redirectUrl = redirectUrl; + } + + if (timezone) { + stateObject.data.timezone = timezone; + } + + // Encode state as base64url (URL-safe) + const stateJson = JSON.stringify(stateObject); + const state = Buffer.from(stateJson).toString("base64url"); + + passport.authenticate("Slack", { + session: false, + state, + })(req, res, next); + } catch (error) { + console.error("Error in loginWithSlack:", { + error: error instanceof Error ? error.message : "Unknown error", + userAgent: req.get("User-Agent"), + query: Object.keys(req.query || {}), + }); + + return res.status(HTTP_STATUS.BAD_REQUEST).json({ + error: "Invalid OAuth request parameters", + }); + } +} + +async function loginWithSlackCallback( + req: Request, + res: Response, + next: NextFunction +) { + passport.authenticate("Slack", { session: false }, (err: any, data: any) => { + if (err || !data) { + return res.redirect( + `${process.env.FRONT_URL}/signin?error=auth_slack_failed` + ); + } + + // Extract user data from the callback + const { user: userData } = data; + + if (!userData || !userData.token || !userData.refreshToken) { + return res.redirect( + `${process.env.FRONT_URL}/signin?error=auth_slack_failed` + ); + } + + const { token, refreshToken } = userData; + + // Secure true for production, secure: true need https + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + // Use custom redirect URL from state data if available + const queryParams = `?token=${token}`; + + return res.redirect(`${process.env.FRONT_URL}/auth-success${queryParams}`); })(req, res, next); } @@ -508,6 +580,8 @@ const AuthController: IAuthController = { login, loginWithGoogle, loginWithGoogleCallback, + loginWithSlack, + loginWithSlackCallback, refresh, logout, forgotPassword, diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 48e3fde..f8dc057 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -30,6 +30,11 @@ authRoutes.get("/google", AuthController.loginWithGoogle); authRoutes.get("/google/callback", AuthController.loginWithGoogleCallback); // **** // +//Slack OAuth2 endpoints +authRoutes.get("/slack", AuthController.loginWithSlack); +authRoutes.get("/slack/callback", AuthController.loginWithSlackCallback); +// **** // + authRoutes.post( "/refresh-token", validateRequest({ diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 1a1e118..e3207f8 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -11,6 +11,7 @@ const UserSchema = z.object({ role: z.string().openapi({ example: "Member" }), timezone: z.string().openapi({ example: "Europe/Paris" }), googleId: z.number().nullable().openapi({ example: 78972475051234701234 }), + slackId: z.string().nullable().openapi({ example: "Z15CFAS8BWW" }), isVerified: z.boolean().openapi({ example: false }), dailyEmailReminder: z.boolean().openapi({ example: false }), email: z.string().email().openapi({ example: "joe.smith@mail.fr" }), diff --git a/src/services/users.services.ts b/src/services/users.services.ts index 7d40c2a..8291291 100755 --- a/src/services/users.services.ts +++ b/src/services/users.services.ts @@ -31,7 +31,8 @@ const findOrCreateUser = async (userData: Partial) => { const user = new User({ email: userData.email, password: null, - googleId: userData.googleId, + googleId: userData?.googleId, + slackId: userData?.slackId, profile: userData.profile || {}, timezone: userData.timezone, isVerified: true, @@ -57,7 +58,16 @@ const findOrCreateUser = async (userData: Partial) => { }); refreshToken = generateRefreshToken(userInfo); - user.set({ refreshToken, googleId: userData.googleId }); + user.set({ refreshToken }); + + if (userData.googleId) { + user.set({ googleId: userData.googleId }); + } + + if (userData.slackId) { + user.set({ slackId: userData.slackId }); + } + await user.save(); } diff --git a/src/types/auth.ts b/src/types/auth.ts index badd797..46b768a 100755 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -15,6 +15,12 @@ export interface IAuthController { res: Response, next: NextFunction ) => void; + loginWithSlack: (req: Request, res: Response, next: NextFunction) => void; + loginWithSlackCallback: ( + req: Request, + res: Response, + next: NextFunction + ) => void; refresh: (req: Request, res: Response) => void; logout: (req: Request, res: Response) => void; forgotPassword: (req: Request, res: Response) => void; diff --git a/src/types/passport-slack-oauth2.d.ts b/src/types/passport-slack-oauth2.d.ts new file mode 100644 index 0000000..8f4b06a --- /dev/null +++ b/src/types/passport-slack-oauth2.d.ts @@ -0,0 +1,54 @@ +declare module "passport-slack-oauth2" { + import { Strategy as OAuth2Strategy } from "passport-oauth2"; + + export interface Profile { + provider: "slack"; + id: string; + displayName?: string; + user: { + id: string; + name?: string; + email?: string; + }; + team: { + id: string; + name: string; + }; + _raw: string; + _json: any; + } + + export interface StrategyOptions { + clientID: string; + clientSecret: string; + callbackURL?: string; + scope?: string | string[]; + skipUserProfile?: boolean; + passReqToCallback?: boolean; + authorizationURL?: string; + tokenURL?: string; + } + + export type VerifyFunction = ( + accessToken: string, + refreshToken: string, + profile: Profile, + done: (error: any, user?: any) => void + ) => void; + + export type VerifyFunctionWithRequest = ( + req: any, + accessToken: string, + refreshToken: string, + profile: Profile, + done: (error: any, user?: any) => void + ) => void; + + export class Strategy extends OAuth2Strategy { + constructor(options: StrategyOptions, verify: VerifyFunction); + constructor(options: StrategyOptions, verify: VerifyFunctionWithRequest); + name: string; + authenticate(req: any, options?: any): void; + userProfile(accessToken: string, done: (err?: any, profile?: any) => void): void; + } +} diff --git a/src/types/users.ts b/src/types/users.ts index c22fbb4..749bcb8 100755 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -12,6 +12,7 @@ export interface IUser extends Document { role: string; timezone: string; googleId: number | null; + slackId: string | null; isVerified: boolean; dailyEmailReminder: boolean; password: string | null; From 4447a1aba3d0ab1ac49b2e5a40e1e8b935343c9a Mon Sep 17 00:00:00 2001 From: HRulier Date: Mon, 1 Sep 2025 17:39:44 +0200 Subject: [PATCH 14/28] fix(auth-slack): wrong callback url --- src/config/passport.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config/passport.ts b/src/config/passport.ts index 1adfbc3..0ae8cd9 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -168,8 +168,7 @@ const slackStrategy = new SlackStrategy( { clientID: SLACK_CLIENT_ID, clientSecret: SLACK_CLIENT_SECRET, - // callbackURL: `https://localhost:1700/api/auth/slack/callback`, - callbackURL: `https://9eafcadf750f.ngrok-free.app/api/auth/slack/callback`, + callbackURL: `${process.env.API_URL}/auth/slack/callback`, passReqToCallback: true, scope: [ // "users.profile:read", From ee1e2aca7cf00559e2531c2667b8f097c95903fe Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 2 Sep 2025 14:50:09 +0200 Subject: [PATCH 15/28] fix: SlackStrategy (passport) issue with scope --- src/config/passport.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/config/passport.ts b/src/config/passport.ts index 0ae8cd9..1e947b8 100755 --- a/src/config/passport.ts +++ b/src/config/passport.ts @@ -170,12 +170,7 @@ const slackStrategy = new SlackStrategy( clientSecret: SLACK_CLIENT_SECRET, callbackURL: `${process.env.API_URL}/auth/slack/callback`, passReqToCallback: true, - scope: [ - // "users.profile:read", - // "users:read", - // "users:read.email", - "identity.basic", - ], + scope: ["identity.basic", "email"], }, async ( req: any, From 004b02657dc86016fe134b06e6ccf624b79f8de3 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 2 Sep 2025 15:57:33 +0200 Subject: [PATCH 16/28] feat: endpoint get userId from slackId --- src/controllers/auth.controller.ts | 18 ++++++++++++++++++ src/openapi/paths/auth.paths.ts | 30 ++++++++++++++++++++++++++++++ src/routes/auth.routes.ts | 12 ++++++++++++ src/types/auth.ts | 1 + 4 files changed, 61 insertions(+) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 1b732b9..1b29a18 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -575,6 +575,23 @@ async function deleteUser(req: Request, res: Response) { } } +// Use to identify user from slack id +// usefull for slack bot +async function getUserIdFromSlackId(req: Request, res: Response) { + try { + const slackId = req.params.id; + const user = await User.findOne({ slackId }); + + if (!user) { + throw NotFound; + } + + return res.status(HTTP_STATUS.OK).json({ userId: user._id }); + } catch (error: unknown) { + return handleError(res, req, error); + } +} + const AuthController: IAuthController = { register, login, @@ -592,6 +609,7 @@ const AuthController: IAuthController = { resendVerificationEmail, getProfile, updateProfile, + getUserIdFromSlackId, deleteUser, }; diff --git a/src/openapi/paths/auth.paths.ts b/src/openapi/paths/auth.paths.ts index b6d2c79..d7d6acd 100644 --- a/src/openapi/paths/auth.paths.ts +++ b/src/openapi/paths/auth.paths.ts @@ -23,6 +23,7 @@ import { resetTokenExpiredResponse, userAlreadyVerifiedExample, } from "~/openapi/examples/auth.examples"; +import IdSchema from "~/schemas/id.schema"; import { unauthorizedResponse, internalServerResponse, @@ -483,6 +484,35 @@ export const registerAuthPaths = () => { }, }); + registry.registerPath({ + method: "get", + path: "/auth/slack/user/{id}", + tags: ["Authentication"], + summary: "Get user id from a slackId", + description: + "Get user id from a slackId, usefull for slack bot and automation workflows", + request: { + params: IdSchema, + }, + responses: { + [HTTP_STATUS.OK]: { + description: "User found, returns user id", + content: { + "application/json": { + schema: z.object({ + userId: z + .string() + .openapi({ example: "67c5c2e9656ca8c7f95f7d52" }), + }), + }, + }, + }, + [HTTP_STATUS.NOT_FOUND]: userNotFoundResponse, + [HTTP_STATUS.UNAUTHORIZED]: unauthorizedResponse, + [HTTP_STATUS.INTERNAL_SERVER_ERROR]: internalServerResponse, + }, + }); + // DELETE /auth/account registry.registerPath({ method: "delete", diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index f8dc057..c35c5c7 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -7,6 +7,9 @@ import { RegisterUserSchema, UpdateUserProfileSchema, } from "~/schemas/user.schema"; +import verifyApiKey from "~/middlewares/verifyApiKey.handler"; + +import IdSchema from "~/schemas/id.schema"; const authRoutes = Router(); @@ -103,6 +106,15 @@ authRoutes.put( AuthController.updateProfile ); +authRoutes.get( + "/slack/user/:id", + validateRequest({ + params: IdSchema, + }), + verifyApiKey, + AuthController.getUserIdFromSlackId +); + authRoutes.delete("/account", requireAuth, AuthController.deleteUser); export default authRoutes; diff --git a/src/types/auth.ts b/src/types/auth.ts index 46b768a..04e8ccb 100755 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -31,5 +31,6 @@ export interface IAuthController { resendVerificationEmail: (req: Request, res: Response) => void; getProfile: (req: IAuthentificateRequest, res: Response) => void; updateProfile: (req: IAuthentificateRequest, res: Response) => void; + getUserIdFromSlackId: (req: Request, res: Response) => void; deleteUser: (req: IAuthentificateRequest, res: Response) => void; } From cf4d29be5e2b23b240293a4db378169db85e78e8 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 2 Sep 2025 16:07:57 +0200 Subject: [PATCH 17/28] fix: fix schema for slack id --- src/routes/auth.routes.ts | 4 ++-- src/schemas/id.schema.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index c35c5c7..492e00e 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -9,7 +9,7 @@ import { } from "~/schemas/user.schema"; import verifyApiKey from "~/middlewares/verifyApiKey.handler"; -import IdSchema from "~/schemas/id.schema"; +import { SlackIdSchema } from "~/schemas/id.schema"; const authRoutes = Router(); @@ -109,7 +109,7 @@ authRoutes.put( authRoutes.get( "/slack/user/:id", validateRequest({ - params: IdSchema, + params: SlackIdSchema, }), verifyApiKey, AuthController.getUserIdFromSlackId diff --git a/src/schemas/id.schema.ts b/src/schemas/id.schema.ts index 4f65fd4..b8c6006 100644 --- a/src/schemas/id.schema.ts +++ b/src/schemas/id.schema.ts @@ -4,4 +4,8 @@ const IdSchema = z.object({ id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID"), }); +export const SlackIdSchema = z.object({ + slackId: z.string().regex(/^[0-9a-fA-F]{11}$/, "Invalid Slack ID"), +}); + export default IdSchema; From e0ced53e42fe48fa0389d3d989ab4a2f3960a2bc Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 2 Sep 2025 16:39:08 +0200 Subject: [PATCH 18/28] fix: fix schema for slack id --- src/controllers/auth.controller.ts | 2 +- src/openapi/paths/auth.paths.ts | 6 +++--- src/routes/auth.routes.ts | 2 +- src/schemas/id.schema.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index 1b29a18..e2309ff 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -579,7 +579,7 @@ async function deleteUser(req: Request, res: Response) { // usefull for slack bot async function getUserIdFromSlackId(req: Request, res: Response) { try { - const slackId = req.params.id; + const { slackId } = req.params; const user = await User.findOne({ slackId }); if (!user) { diff --git a/src/openapi/paths/auth.paths.ts b/src/openapi/paths/auth.paths.ts index d7d6acd..0a47db3 100644 --- a/src/openapi/paths/auth.paths.ts +++ b/src/openapi/paths/auth.paths.ts @@ -23,7 +23,7 @@ import { resetTokenExpiredResponse, userAlreadyVerifiedExample, } from "~/openapi/examples/auth.examples"; -import IdSchema from "~/schemas/id.schema"; +import { SlackIdSchema } from "~/schemas/id.schema"; import { unauthorizedResponse, internalServerResponse, @@ -486,13 +486,13 @@ export const registerAuthPaths = () => { registry.registerPath({ method: "get", - path: "/auth/slack/user/{id}", + path: "/auth/slack/user/{slackId}", tags: ["Authentication"], summary: "Get user id from a slackId", description: "Get user id from a slackId, usefull for slack bot and automation workflows", request: { - params: IdSchema, + params: SlackIdSchema, }, responses: { [HTTP_STATUS.OK]: { diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 492e00e..4dd338c 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -107,7 +107,7 @@ authRoutes.put( ); authRoutes.get( - "/slack/user/:id", + "/slack/user/:slackId", validateRequest({ params: SlackIdSchema, }), diff --git a/src/schemas/id.schema.ts b/src/schemas/id.schema.ts index b8c6006..2848658 100644 --- a/src/schemas/id.schema.ts +++ b/src/schemas/id.schema.ts @@ -5,7 +5,7 @@ const IdSchema = z.object({ }); export const SlackIdSchema = z.object({ - slackId: z.string().regex(/^[0-9a-fA-F]{11}$/, "Invalid Slack ID"), + slackId: z.string().regex(/^[0-9a-zA-Z]{11}$/, "Invalid Slack ID"), }); export default IdSchema; From 4968ef504b38940c495f81d03bbfa4a4a7d62c39 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 2 Sep 2025 16:58:16 +0200 Subject: [PATCH 19/28] feat(user-model): add pproperty slackId --- src/models/user.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/models/user.ts b/src/models/user.ts index 345d318..6a00b89 100755 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -35,6 +35,10 @@ const UserSchema = new Schema( type: Number, default: null, }, + slackId: { + type: String, + default: null, + }, isVerified: { type: Boolean, required: true, From ff6d5ce19c02af1beb1fea1e5aa6aa9683e82a76 Mon Sep 17 00:00:00 2001 From: HRulier Date: Wed, 3 Sep 2025 15:58:47 +0200 Subject: [PATCH 20/28] feat(post tasks/bulk): create endpoint for n8n automation to post multiple tasks for a user --- src/controllers/tasks.controller.ts | 54 ++++++++++++++++++++++++++- src/openapi/examples/task.examples.ts | 22 ++++++++++- src/openapi/paths/task.paths.ts | 41 ++++++++++++++++++++ src/routes/task.routes.ts | 11 ++++++ src/schemas/task.schema.ts | 17 ++++++++- src/types/task.ts | 12 +++++- 6 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/controllers/tasks.controller.ts b/src/controllers/tasks.controller.ts index ed01349..f84e784 100644 --- a/src/controllers/tasks.controller.ts +++ b/src/controllers/tasks.controller.ts @@ -1,10 +1,11 @@ -import { Response } from "express"; +import { Response, Request } from "express"; import HTTP_STATUS from "~/utils/http_status"; import { NotFoundError, handleError } from "~/utils/errors"; import { isValid } from "date-fns"; import { ITaskController, CreateTaskInput, + CreateTasksWithUserInput, UpdateTaskInput, } from "~/types/task"; import { IAuthentificateRequest } from "~/types/auth"; @@ -109,6 +110,56 @@ async function createTask(req: IAuthentificateRequest, res: Response) { } } +async function createTasks(req: Request, res: Response) { + try { + const { user, tasks }: CreateTasksWithUserInput = req.body; + + // Group tasks by dueDate + const tasksByDueDate = new Map(); + tasks.forEach((task: CreateTaskInput) => { + const dateKey = task.dueDate.toISOString(); + if (!tasksByDueDate.has(dateKey)) { + tasksByDueDate.set(dateKey, []); + } + tasksByDueDate.get(dateKey)!.push(task); + }); + + const newTasks: (CreateTaskInput & { user: string })[] = []; + + // Process tasks for each dueDate + for (const [dateKey, groupTasks] of tasksByDueDate) { + const dueDate = new Date(dateKey); + + const minPositionTask = await Task.findOne({ + user, + dueDate, + }).sort({ position: 1 }); + + let startingPosition = minPositionTask + ? minPositionTask.position - 1 + : 1024; + + groupTasks.forEach((task, index) => { + newTasks.push({ + description: task.description, + dueDate: task.dueDate, + user: user, + position: startingPosition - index, + tags: task.tags || [], + }); + }); + } + + const createdTasks = await Task.insertMany(newTasks); + + await Task.populate(createdTasks, populateTask); + + return res.status(HTTP_STATUS.CREATED).json({ tasks: createdTasks }); + } catch (error: unknown) { + return handleError(res, req, error); + } +} + async function updateTask(req: IAuthentificateRequest, res: Response) { try { const user = req.user as IUser; @@ -153,6 +204,7 @@ const TaskController: ITaskController = { getTasks, getTaskById, createTask, + createTasks, updateTask, deleteTask, }; diff --git a/src/openapi/examples/task.examples.ts b/src/openapi/examples/task.examples.ts index 1510e5f..ee26ad7 100644 --- a/src/openapi/examples/task.examples.ts +++ b/src/openapi/examples/task.examples.ts @@ -1,7 +1,11 @@ import { getErrorResponseConfig } from "~/openapi/utils"; import { generateZodValidationErrorExample } from "~/utils/zod/zod-error-generator"; import IdSchema from "~/schemas/id.schema"; -import { CreateTaskSchema, GetTasksQuerySchema } from "~/schemas/task.schema"; +import { + CreateTaskSchema, + CreateTasksWithUserSchema, + GetTasksQuerySchema, +} from "~/schemas/task.schema"; // ------------------------------------- // Invalid Data @@ -18,6 +22,16 @@ const invalidTaskExample = { dueDate: "date", }; +const invalidTasksBulkExample = { + user: "68b83c279a7ad44d7be69bfe", + tasks: [ + { + description: 10, + dueDate: "date", + }, + ], +}; + const invalidGetTasksQueryExample = { minDate: "date", }; @@ -31,6 +45,11 @@ const taskValidationExample = generateZodValidationErrorExample( invalidTaskExample ); +const tasksBulkValidationExample = generateZodValidationErrorExample( + CreateTasksWithUserSchema, + invalidTasksBulkExample +); + const taskIdValidationExample = generateZodValidationErrorExample(IdSchema, { id: "invalid id", }); @@ -43,6 +62,7 @@ const getTasksValidationExample = generateZodValidationErrorExample( export { // Validation errors taskValidationExample, + tasksBulkValidationExample, taskIdValidationExample, getTasksValidationExample, // Error responses diff --git a/src/openapi/paths/task.paths.ts b/src/openapi/paths/task.paths.ts index a4dcb36..452032b 100644 --- a/src/openapi/paths/task.paths.ts +++ b/src/openapi/paths/task.paths.ts @@ -5,11 +5,13 @@ import { TaskSchema, CreateTaskSchema, UpdateTaskSchema, + CreateTasksWithUserSchema, } from "~/schemas/task.schema"; import { taskNotFoundResponse, taskIdValidationExample, taskValidationExample, + tasksBulkValidationExample, getTasksValidationExample, } from "../examples/task.examples"; import { ErrorSchema } from "~/schemas/error.schema"; @@ -153,6 +155,45 @@ export const registerTaskPaths = () => { }, }); + // POST /tasks/bulk + registry.registerPath({ + method: "post", + path: "/tasks/bulk", + tags: ["Tasks"], + summary: "Create a new tasks for a specific user from Slack", + security: [{ bearerAuth: [] }], + request: { + body: { + content: { + "application/json": { + schema: CreateTasksWithUserSchema, + }, + }, + }, + }, + responses: { + [HTTP_STATUS.CREATED]: { + description: "Tasks created successfully", + content: { + "application/json": { + schema: z.object({ tasks: z.array(TaskSchema) }), + }, + }, + }, + [HTTP_STATUS.BAD_REQUEST]: { + description: "Validation error - missing or invalid fields", + content: { + "application/json": { + schema: ErrorSchema, + example: tasksBulkValidationExample, + }, + }, + }, + [HTTP_STATUS.UNAUTHORIZED]: unauthorizedResponse, + [HTTP_STATUS.INTERNAL_SERVER_ERROR]: internalServerResponse, + }, + }); + // PUT /tasks/{id} registry.registerPath({ method: "put", diff --git a/src/routes/task.routes.ts b/src/routes/task.routes.ts index 17747e9..6ae6301 100644 --- a/src/routes/task.routes.ts +++ b/src/routes/task.routes.ts @@ -2,11 +2,13 @@ import { Router } from "express"; import TaskController from "~/controllers/tasks.controller"; import { requireAuth } from "~/middlewares/auth.handler"; import validateRequest from "~/middlewares/validateRequest.handler"; +import verifyApiKey from "~/middlewares/verifyApiKey.handler"; import IdSchema from "~/schemas/id.schema"; import { CreateTaskSchema, UpdateTaskSchema, GetTasksQuerySchema, + CreateTasksWithUserSchema, } from "~/schemas/task.schema"; const tasksRoutes = Router(); @@ -36,6 +38,15 @@ tasksRoutes.post( TaskController.createTask ); +tasksRoutes.post( + "/bulk", + validateRequest({ + body: CreateTasksWithUserSchema, + }), + verifyApiKey, + TaskController.createTasks +); + tasksRoutes.put( "/:id", requireAuth, diff --git a/src/schemas/task.schema.ts b/src/schemas/task.schema.ts index 8b4a59e..d10c6a4 100644 --- a/src/schemas/task.schema.ts +++ b/src/schemas/task.schema.ts @@ -58,6 +58,15 @@ const CreateTaskSchema = z.object({ }), }); +const CreateTasksWithUserSchema = z.object({ + user: z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") + .openapi({ + example: "67c5c2e9656ca8c7f95f7d52", + }), + tasks: z.array(CreateTaskSchema), +}); // Set fields as optional for update const UpdateTaskSchema = CreateTaskSchema.partial().extend({ completed: z.boolean().default(false).openapi({ example: true }), @@ -65,4 +74,10 @@ const UpdateTaskSchema = CreateTaskSchema.partial().extend({ registry.register("Task", TaskSchema); -export { TaskSchema, CreateTaskSchema, UpdateTaskSchema, GetTasksQuerySchema }; +export { + TaskSchema, + CreateTaskSchema, + CreateTasksWithUserSchema, + UpdateTaskSchema, + GetTasksQuerySchema, +}; diff --git a/src/types/task.ts b/src/types/task.ts index 96e4402..28d489a 100755 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -1,12 +1,19 @@ -import { Response } from "express"; +import { Response, Request } from "express"; import mongoose from "mongoose"; import z from "~/utils/zod/zod-extended"; import { IUser } from "./users"; import { IAuthentificateRequest } from "./auth"; -import { CreateTaskSchema, UpdateTaskSchema } from "~/schemas/task.schema"; +import { + CreateTaskSchema, + CreateTasksWithUserSchema, + UpdateTaskSchema, +} from "~/schemas/task.schema"; // Types inferred export type CreateTaskInput = z.infer; +export type CreateTasksWithUserInput = z.infer< + typeof CreateTasksWithUserSchema +>; export type UpdateTaskInput = z.infer; // Extend type for task document @@ -24,6 +31,7 @@ export interface ITaskController { getTasks: (req: IAuthentificateRequest, res: Response) => void; getTaskById: (req: IAuthentificateRequest, res: Response) => void; createTask: (req: IAuthentificateRequest, res: Response) => void; + createTasks: (req: Request, res: Response) => void; updateTask: (req: IAuthentificateRequest, res: Response) => void; deleteTask: (req: IAuthentificateRequest, res: Response) => void; } From c77ce2b999d2c210cbe61d415b588ca25f3866c4 Mon Sep 17 00:00:00 2001 From: HRulier Date: Fri, 5 Sep 2025 09:59:27 +0200 Subject: [PATCH 21/28] feat(operations): create model operation, schema, types and test, POST /operations --- src/controllers/operation.controller.ts | 74 +++++++++ src/models/operation.ts | 53 +++++++ src/openapi/examples/operation.examples.ts | 43 ++++++ src/openapi/index.ts | 5 + src/openapi/paths/index.ts | 2 + src/openapi/paths/operation.paths.ts | 55 +++++++ src/routes/index.ts | 2 + src/routes/operation.routes.ts | 16 ++ src/schemas/operation.schema.ts | 122 +++++++++++++++ src/types/operation.ts | 10 ++ tests/endpoints/operation.test.ts | 170 +++++++++++++++++++++ 11 files changed, 552 insertions(+) create mode 100644 src/controllers/operation.controller.ts create mode 100644 src/models/operation.ts create mode 100644 src/openapi/examples/operation.examples.ts create mode 100644 src/openapi/paths/operation.paths.ts create mode 100644 src/routes/operation.routes.ts create mode 100644 src/schemas/operation.schema.ts create mode 100644 src/types/operation.ts create mode 100644 tests/endpoints/operation.test.ts diff --git a/src/controllers/operation.controller.ts b/src/controllers/operation.controller.ts new file mode 100644 index 0000000..d48066a --- /dev/null +++ b/src/controllers/operation.controller.ts @@ -0,0 +1,74 @@ +import { Response, Request } from "express"; +import HTTP_STATUS from "~/utils/http_status"; +import { CustomError } from "~/utils/errors"; +import { handleError } from "~/utils/errors"; +import { IOperationController } from "~/types/operation"; +import User from "~/models/user"; +import { CreateTaskSchema } from "~/schemas/task.schema"; +import Operation from "~/models/operation"; +import { ZodError } from "zod"; + +async function createOperation(req: Request, res: Response) { + try { + const { user, source, type, payload, metadata } = req.body; + + let userId: string | null = null; + + if (user && source === "slack") { + const slackUser = await User.findOne({ slackId: user.id }); + userId = slackUser?._id || null; + } + + if (!userId) { + throw new CustomError("User not found", HTTP_STATUS.BAD_REQUEST); + } + + const operation = new Operation({ + user: userId.toString(), + source, + type, + payload, + metadata: metadata || {}, + }); + + if (type === "bulk_create_tasks") { + const tasks = payload.tasks; + + let error = { + ZodError: {}, + index: 0, + }; + tasks.every((task: unknown, index: number) => { + const validation = CreateTaskSchema.safeParse(task); + if (!validation.success) { + error.ZodError = validation.error; + error.index = index; + } + return validation.success; + }); + + if (error.ZodError instanceof ZodError) { + return res.status(HTTP_STATUS.BAD_REQUEST).json({ + status: "error", + message: `Validation failed, tasks at index ${error.index}`, + errors: error.ZodError.errors.map((err) => ({ + path: err.path.join("."), + message: err.message, + })), + }); + } + } + + await operation.save(); + + return res.status(HTTP_STATUS.CREATED).json({ operation }); + } catch (error: unknown) { + return handleError(res, req, error); + } +} + +const OperationController: IOperationController = { + createOperation, +}; + +export default OperationController; diff --git a/src/models/operation.ts b/src/models/operation.ts new file mode 100644 index 0000000..b8daba5 --- /dev/null +++ b/src/models/operation.ts @@ -0,0 +1,53 @@ +import mongoose from "mongoose"; +import { OperationDocument } from "~/types/operation"; +const Schema = mongoose.Schema; + +const OperationSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + source: { + type: String, + required: true, + enum: ["slack"], + }, + status: { + type: String, + required: true, + enum: ["pending", "approved", "rejected"], + default: "pending", + }, + type: { + type: String, + required: true, + enum: ["bulk_create_tasks"], + }, + payload: { + type: Schema.Types.Mixed, + required: true, + }, + metadata: { + channel: { + type: String, + default: null, + }, + approvedBy: { + type: Schema.Types.ObjectId, + ref: "User", + default: null, + }, + approvedAt: { + type: Date, + default: null, + }, + }, + }, + { + timestamps: true, + } +); + +export default mongoose.model("Operation", OperationSchema); diff --git a/src/openapi/examples/operation.examples.ts b/src/openapi/examples/operation.examples.ts new file mode 100644 index 0000000..0ef60f4 --- /dev/null +++ b/src/openapi/examples/operation.examples.ts @@ -0,0 +1,43 @@ +import { generateZodValidationErrorExample } from "~/utils/zod/zod-error-generator"; +import { CreateOperationSchema } from "~/schemas/operation.schema"; + +// ------------------------------------- +// Invalid Data +// -------------------------------------- +const invalidOperationExample = { + source: "chat", + user: "U09DRSE6HDW", + type: "bulk_create_tasks", + payload: { + tasks: [ + { + description: "Review quarterly sales report", + dueDate: "2025-09-05T09:00:00.000Z", + }, + { + description: "Schedule team meeting", + dueDate: "2025-09-05T16:00:00.000Z", + }, + { + description: "Update project documentation", + dueDate: "2025-09-06T10:00:00.000Z", + }, + ], + }, + metadata: { + channel: "D09D3PD3RB8", + }, +}; +// ------------------------------------- +// Generated Validation errors +// ------------------------------------- + +const operationValidationExample = generateZodValidationErrorExample( + CreateOperationSchema, + invalidOperationExample +); + +export { + // Validation errors + operationValidationExample, +}; diff --git a/src/openapi/index.ts b/src/openapi/index.ts index 85bd0bc..d9c29cf 100644 --- a/src/openapi/index.ts +++ b/src/openapi/index.ts @@ -29,6 +29,11 @@ export const createOpenApiDocument = () => { tags: [ { name: "Authentication", description: "Auth management operations" }, { name: "Tasks", description: "Tasks management operations" }, + { + name: "Operations", + description: + "Operations management, operations are created by external service through API calls", + }, ], }); }; diff --git a/src/openapi/paths/index.ts b/src/openapi/paths/index.ts index 04189de..029226d 100644 --- a/src/openapi/paths/index.ts +++ b/src/openapi/paths/index.ts @@ -1,7 +1,9 @@ import { registerTaskPaths } from "./task.paths"; import { registerAuthPaths } from "./auth.paths"; import { registerTagPaths } from "./tag.paths"; +import { registerOperationPaths } from "./operation.paths"; registerAuthPaths(); registerTaskPaths(); registerTagPaths(); +registerOperationPaths(); diff --git a/src/openapi/paths/operation.paths.ts b/src/openapi/paths/operation.paths.ts new file mode 100644 index 0000000..5167f2a --- /dev/null +++ b/src/openapi/paths/operation.paths.ts @@ -0,0 +1,55 @@ +import registry from "../registry"; +import z from "~/utils/zod/zod-extended"; +import HTTP_STATUS from "~/utils/http_status"; +import { + OperationSchema, + CreateOperationSchema, +} from "~/schemas/operation.schema"; +import { operationValidationExample } from "../examples/operation.examples"; +import { ErrorSchema } from "~/schemas/error.schema"; +import { + unauthorizedResponse, + internalServerResponse, +} from "~/openapi/examples/error.examples"; + +// Register paths +export const registerOperationPaths = () => { + // POST /operations + registry.registerPath({ + method: "post", + path: "/operations", + tags: ["Operations"], + summary: "Create a new operation", + security: [{ bearerAuth: [] }], + request: { + body: { + content: { + "application/json": { + schema: CreateOperationSchema, + }, + }, + }, + }, + responses: { + [HTTP_STATUS.CREATED]: { + description: "Operation created successfully", + content: { + "application/json": { + schema: z.object({ tag: OperationSchema }), + }, + }, + }, + [HTTP_STATUS.BAD_REQUEST]: { + description: "Validation error - missing or invalid fields", + content: { + "application/json": { + schema: ErrorSchema, + example: operationValidationExample, + }, + }, + }, + [HTTP_STATUS.UNAUTHORIZED]: unauthorizedResponse, + [HTTP_STATUS.INTERNAL_SERVER_ERROR]: internalServerResponse, + }, + }); +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index fa79893..43f9f8e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -12,6 +12,7 @@ import AuthRoutes from "./auth.routes"; import TasksRoutes from "./task.routes"; import TagsRoutes from "./tag.routes"; import JobsRoutes from "./job.routes"; +import OperationRoutes from "./operation.routes"; import { createOpenApiDocument } from "~/openapi"; dotenv.config(dotEnvConfig); @@ -32,6 +33,7 @@ export default function (app: Application) { apiRoutes.use("/tasks", TasksRoutes); apiRoutes.use("/tags", TagsRoutes); apiRoutes.use("/jobs", JobsRoutes); + apiRoutes.use("/operations", OperationRoutes); // Set url for API group routes app.use("/api", apiRoutes); diff --git a/src/routes/operation.routes.ts b/src/routes/operation.routes.ts new file mode 100644 index 0000000..dfa7dce --- /dev/null +++ b/src/routes/operation.routes.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import OperationController from "~/controllers/operation.controller"; +import verifyApiKey from "~/middlewares/verifyApiKey.handler"; +import validateRequest from "~/middlewares/validateRequest.handler"; +import { CreateOperationSchema } from "~/schemas/operation.schema"; + +const operationRoutes = Router(); + +operationRoutes.post( + "/", + verifyApiKey, + validateRequest({ body: CreateOperationSchema }), + OperationController.createOperation +); + +export default operationRoutes; diff --git a/src/schemas/operation.schema.ts b/src/schemas/operation.schema.ts new file mode 100644 index 0000000..af271cb --- /dev/null +++ b/src/schemas/operation.schema.ts @@ -0,0 +1,122 @@ +import registry from "~/openapi/registry"; +import z from "~/utils/zod/zod-extended"; + +const OperationSchema = z.object({ + _id: z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") + .openapi({ + example: "67c5c2e9656ca8c7f95f7d52", + }), + user: z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") + .openapi({ + example: "67c5c2e9656ca8c7f95f7d52", + }), + source: z.enum(["slack"]).openapi({ + example: "slack", + description: "Source of the operation", + }), + status: z.enum(["pending", "approved", "rejected"]).openapi({ + example: "pending", + description: "Current status of the operation", + }), + type: z.enum(["bulk_create_tasks"]).openapi({ + example: "bulk_create_tasks", + description: "Type of operation to perform", + }), + payload: z.record(z.any()).openapi({ + example: { + tasks: [ + { + description: "Task 1", + dueDate: "2025-07-18T14:55:37.403Z", + }, + { + description: "Task 2", + dueDate: "2025-07-18T14:55:37.403Z", + }, + ], + }, + description: "Operation payload data", + }), + metadata: z + .object({ + channel: z.string().nullable().openapi({ + example: "D09D3PD3RB8", + description: "Channel where the operation was initiated", + }), + approvedBy: z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") + .nullable() + .openapi({ + example: "67c5c2e9656ca8c7f95f7d52", + description: "User ID who approved the operation", + }), + approvedAt: z.coerce.date().nullable().openapi({ + example: "2025-07-18T14:55:37.403Z", + description: "Date when the operation was approved", + }), + }) + .openapi({ description: "Additional metadata for the operation" }), + createdAt: z.coerce.date().openapi({ example: "2025-07-18T14:55:37.403Z" }), + updatedAt: z.coerce.date().openapi({ example: "2025-07-18T14:55:37.403Z" }), +}); + +const CreateOperationSchema = z.object({ + user: z.string().nullable().openapi({ + example: "slack", + }), + source: z.enum(["slack"]).openapi({ + example: "slack", + description: "Source of the operation", + }), + type: z.enum(["bulk_create_tasks"]).openapi({ + example: "bulk_create_tasks", + description: "Type of operation to perform", + }), + payload: z.record(z.any()).openapi({ + example: { + tasks: [ + { + description: "Task 1", + dueDate: "2025-07-18T14:55:37.403Z", + }, + { + description: "Task 2", + dueDate: "2025-07-18T14:55:37.403Z", + }, + ], + }, + description: "Operation payload data", + }), + metadata: z + .object({ + channel: z.string().nullable().optional().openapi({ + example: "D09D3PD3RB8", + description: "Channel where the operation was initiated", + }), + approvedBy: z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") + .nullable() + .optional() + .openapi({ + example: "67c5c2e9656ca8c7f95f7d52", + description: "User ID who approved the operation", + }), + approvedAt: z.coerce.date().nullable().optional().openapi({ + example: "2025-07-18T14:55:37.403Z", + description: "Date when the operation was approved", + }), + }) + .optional() + .openapi({ description: "Additional metadata for the operation" }), +}); + +registry.register("Operation", OperationSchema); +registry.register("CreateOperation", CreateOperationSchema); + +export { OperationSchema, CreateOperationSchema }; diff --git a/src/types/operation.ts b/src/types/operation.ts new file mode 100644 index 0000000..723a6c0 --- /dev/null +++ b/src/types/operation.ts @@ -0,0 +1,10 @@ +import z from "~/utils/zod/zod-extended"; +import { Response } from "express"; +import { IAuthentificateRequest } from "./auth"; +import { OperationSchema } from "~/schemas/operation.schema"; + +export type OperationDocument = z.infer; + +export interface IOperationController { + createOperation: (req: IAuthentificateRequest, res: Response) => void; +} \ No newline at end of file diff --git a/tests/endpoints/operation.test.ts b/tests/endpoints/operation.test.ts new file mode 100644 index 0000000..6482449 --- /dev/null +++ b/tests/endpoints/operation.test.ts @@ -0,0 +1,170 @@ +import request from "supertest"; +import dotenv from "dotenv"; +import app from "~/server"; +import Operation from "~/models/operation"; +import configDotenv from "~/config/dot-env"; + +dotenv.config(configDotenv); + +describe("Operation endpoints tests", () => { + afterAll(async () => { + await Operation.deleteMany({}); + }); + + describe("POST /operations", () => { + afterEach(async () => { + await Operation.deleteMany({}); + }); + + it("Should create a new operation", async () => { + const operationData = { + source: "slack", + type: "bulk_create_tasks", + user: "U09DRSE6HDW", + payload: { + tasks: [ + { + description: "Task 1", + dueDate: "2025-07-18T14:55:37.403Z", + }, + { + description: "Task 2", + dueDate: "2025-07-18T14:55:37.403Z", + }, + ], + }, + metadata: { + channel: "D09D3PD3RB8", + approvedBy: null, + approvedAt: null, + }, + }; + + const { status, body } = await request(app) + .post("/api/operations") + .send(operationData) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(201); + expect(body).toHaveProperty("operation"); + expect(body.operation.source).toBe(operationData.source); + expect(body.operation.type).toBe(operationData.type); + expect(body.operation.status).toBe("pending"); // Default status + expect(body.operation.payload).toEqual(operationData.payload); + expect(body.operation.metadata.channel).toBe( + operationData.metadata.channel + ); + }); + + it("Should create operation without metadata", async () => { + const operationData = { + source: "slack", + type: "bulk_create_tasks", + user: "U09DRSE6HDW", + payload: { + tasks: [ + { + description: "Task 1", + dueDate: "2025-07-18T14:55:37.403Z", + }, + ], + }, + }; + + const { status, body } = await request(app) + .post("/api/operations") + .send(operationData) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(201); + expect(body).toHaveProperty("operation"); + expect(body.operation.source).toBe(operationData.source); + expect(body.operation.type).toBe(operationData.type); + expect(body.operation.metadata).toEqual({ + approvedBy: null, + approvedAt: null, + channel: null, + }); + }); + + it("Should return 401 for unauthenticated request", async () => { + const operationData = { + source: "slack", + type: "bulk_create_tasks", + payload: { + tasks: [{ description: "Unauthorized Task" }], + }, + }; + + const { status } = await request(app) + .post("/api/operations") + .send(operationData) + .set("Accept", "application/json"); + + expect(status).toBe(401); + }); + + it("Should return validation error for invalid source", async () => { + const operationData = { + source: "invalid_source", + type: "bulk_create_tasks", + payload: { + tasks: [{ description: "Task with invalid source" }], + }, + }; + + const { status } = await request(app) + .post("/api/operations") + .send(operationData) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + + it("Should return validation error for invalid type", async () => { + const operationData = { + source: "slack", + type: "invalid_type", + payload: { + tasks: [{ description: "Task with invalid type" }], + }, + }; + + const { status } = await request(app) + .post("/api/operations") + .send(operationData) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + + it("Should return validation error for missing required fields", async () => { + const { status } = await request(app) + .post("/api/operations") + .send({}) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + + it("Should return validation error for missing payload", async () => { + const operationData = { + source: "slack", + type: "bulk_create_tasks", + }; + + const { status } = await request(app) + .post("/api/operations") + .send(operationData) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + }); +}); From 38b16eca1b6b9eda6c6b0208b0406b5643226eb2 Mon Sep 17 00:00:00 2001 From: HRulier Date: Fri, 5 Sep 2025 17:12:54 +0200 Subject: [PATCH 22/28] feat(operations): handle generate shortId, create endpoint PATCH /operations/:shortId --- package-lock.json | 31 +++++++++--- package.json | 1 + src/controllers/operation.controller.ts | 45 ++++++++++++++++- src/models/operation.ts | 36 +++++++++++++ src/routes/operation.routes.ts | 10 ++++ src/schemas/operation.schema.ts | 3 ++ src/services/tasks.service.ts | 67 +++++++++++++++++++++++++ src/types/operation.ts | 8 +-- 8 files changed, 190 insertions(+), 11 deletions(-) create mode 100755 src/services/tasks.service.ts diff --git a/package-lock.json b/package-lock.json index a7e700c..e1b64f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.11.0", "morgan": "^1.10.0", + "nanoid": "^5.1.5", "nodemon": "^3.1.9", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", @@ -7910,10 +7911,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", "funding": [ { "type": "github", @@ -7922,10 +7922,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -8658,6 +8658,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 55a22f2..b2eea58 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "jsonwebtoken": "^9.0.2", "mongoose": "^8.11.0", "morgan": "^1.10.0", + "nanoid": "^5.1.5", "nodemon": "^3.1.9", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", diff --git a/src/controllers/operation.controller.ts b/src/controllers/operation.controller.ts index d48066a..8da70d1 100644 --- a/src/controllers/operation.controller.ts +++ b/src/controllers/operation.controller.ts @@ -1,13 +1,18 @@ import { Response, Request } from "express"; import HTTP_STATUS from "~/utils/http_status"; -import { CustomError } from "~/utils/errors"; +import { CustomError, NotFoundError } from "~/utils/errors"; import { handleError } from "~/utils/errors"; import { IOperationController } from "~/types/operation"; import User from "~/models/user"; import { CreateTaskSchema } from "~/schemas/task.schema"; +import { createTasks } from "~/services/tasks.service"; import Operation from "~/models/operation"; import { ZodError } from "zod"; +const NotFound = new NotFoundError( + "The requested operation doest not exist or has already been executed" +); + async function createOperation(req: Request, res: Response) { try { const { user, source, type, payload, metadata } = req.body; @@ -31,6 +36,7 @@ async function createOperation(req: Request, res: Response) { metadata: metadata || {}, }); + // Validate tasks before saving if (type === "bulk_create_tasks") { const tasks = payload.tasks; @@ -67,8 +73,45 @@ async function createOperation(req: Request, res: Response) { } } +async function updateAndExecuteOperation(req: Request, res: Response) { + try { + const { shortId } = req.params; + const { status } = req.body; + + const operation = await Operation.findOne({ shortId, status: "pending" }); + + if (!operation) { + throw NotFound; + } + + if ( + operation.status === "pending" && + operation.type === "bulk_create_tasks" && + status === "approved" + ) { + const newTasks = operation.payload.tasks.map((task: any) => ({ + ...task, + })); + + await createTasks(newTasks, operation.user); + } + + // find by status prevent to update an already executed operation + const updatedOperation = await Operation.findOneAndUpdate( + { shortId, status: "pending" }, + { status }, + { new: true } + ); + + return res.status(HTTP_STATUS.OK).json({ operation: updatedOperation }); + } catch (error: unknown) { + return handleError(res, req, error); + } +} + const OperationController: IOperationController = { createOperation, + updateAndExecuteOperation, }; export default OperationController; diff --git a/src/models/operation.ts b/src/models/operation.ts index b8daba5..7e64098 100644 --- a/src/models/operation.ts +++ b/src/models/operation.ts @@ -1,4 +1,5 @@ import mongoose from "mongoose"; +import { customAlphabet } from "nanoid"; import { OperationDocument } from "~/types/operation"; const Schema = mongoose.Schema; @@ -9,6 +10,9 @@ const OperationSchema = new Schema( ref: "User", required: true, }, + shortId: { + type: String, + }, source: { type: String, required: true, @@ -50,4 +54,36 @@ const OperationSchema = new Schema( } ); +OperationSchema.pre("save", async function (next) { + if (!this.shortId) { + let attempts = 0; + const maxAttempts = 5; + + while (attempts < maxAttempts) { + try { + const nanoidCustom = customAlphabet( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + 11 + ); + const id = nanoidCustom(); + const found = await (this.constructor as any).findOne({ shortId: id }); + if (!found) { + this.shortId = id; + break; + } + attempts++; + } catch (error) { + throw error; + } + } + + if (attempts === maxAttempts) { + throw new Error("Failed to generate unique operation ID"); + } + } + next(); +}); + +OperationSchema.index({ shortId: 1 }, { unique: true }); + export default mongoose.model("Operation", OperationSchema); diff --git a/src/routes/operation.routes.ts b/src/routes/operation.routes.ts index dfa7dce..7edbc8f 100644 --- a/src/routes/operation.routes.ts +++ b/src/routes/operation.routes.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import z from "~/utils/zod/zod-extended"; import OperationController from "~/controllers/operation.controller"; import verifyApiKey from "~/middlewares/verifyApiKey.handler"; import validateRequest from "~/middlewares/validateRequest.handler"; @@ -13,4 +14,13 @@ operationRoutes.post( OperationController.createOperation ); +operationRoutes.patch( + "/:shortId", + verifyApiKey, + validateRequest({ + body: z.object({ status: z.enum(["pending", "approved", "rejected"]) }), + }), + OperationController.updateAndExecuteOperation +); + export default operationRoutes; diff --git a/src/schemas/operation.schema.ts b/src/schemas/operation.schema.ts index af271cb..395dc65 100644 --- a/src/schemas/operation.schema.ts +++ b/src/schemas/operation.schema.ts @@ -14,6 +14,9 @@ const OperationSchema = z.object({ .openapi({ example: "67c5c2e9656ca8c7f95f7d52", }), + shortId: z.string().openapi({ + example: "MFAFU856DVV", + }), source: z.enum(["slack"]).openapi({ example: "slack", description: "Source of the operation", diff --git a/src/services/tasks.service.ts b/src/services/tasks.service.ts new file mode 100755 index 0000000..25cbb07 --- /dev/null +++ b/src/services/tasks.service.ts @@ -0,0 +1,67 @@ +import dotenv from "dotenv"; +import dotEnvConfig from "~/config/dot-env"; +import type { CreateTaskInput } from "~/types/task"; +import Task from "~/models/task"; + +dotenv.config(dotEnvConfig); + +const populateTask = [ + { + path: "tags", + select: "_id label color", + }, +]; + +type TaskProps = Omit & { dueDate: string | Date }; + +// Create tasks and determine position if missing +const createTasks = async (tasks: TaskProps[], user: string) => { + try { + // Group tasks by dueDate + const tasksByDueDate = new Map(); + tasks.forEach((task: TaskProps) => { + const dateKey = + typeof task.dueDate === "string" + ? task.dueDate + : task.dueDate.toISOString(); + if (!tasksByDueDate.has(dateKey)) { + tasksByDueDate.set(dateKey, []); + } + tasksByDueDate.get(dateKey)!.push(task); + }); + + const newTasks: (TaskProps & { user: string })[] = []; + + // Process tasks for each dueDate + for (const [dateKey, groupTasks] of tasksByDueDate) { + const dueDate = new Date(dateKey); + + const minPositionTask = await Task.findOne({ + user, + dueDate, + }).sort({ position: 1 }); + + let startingPosition = minPositionTask + ? minPositionTask.position - 1 + : 1024; + + groupTasks.forEach((task, index) => { + newTasks.push({ + ...task, + user, + position: startingPosition - index, + }); + }); + } + + const createdTasks = await Task.insertMany(newTasks); + await Task.populate(createdTasks, populateTask); + + return createdTasks; + } catch (err) { + console.log(err); + throw err; + } +}; + +export { createTasks }; diff --git a/src/types/operation.ts b/src/types/operation.ts index 723a6c0..a7c68c0 100644 --- a/src/types/operation.ts +++ b/src/types/operation.ts @@ -1,10 +1,10 @@ import z from "~/utils/zod/zod-extended"; -import { Response } from "express"; -import { IAuthentificateRequest } from "./auth"; +import { Response, Request } from "express"; import { OperationSchema } from "~/schemas/operation.schema"; export type OperationDocument = z.infer; export interface IOperationController { - createOperation: (req: IAuthentificateRequest, res: Response) => void; -} \ No newline at end of file + createOperation: (req: Request, res: Response) => void; + updateAndExecuteOperation: (req: Request, res: Response) => void; +} From 3d57aad24fd79e2c99d324fca91b65d672178af0 Mon Sep 17 00:00:00 2001 From: HRulier Date: Fri, 5 Sep 2025 17:25:43 +0200 Subject: [PATCH 23/28] feat(operations): add test for PATCH --- jest.config.ts | 1 + tests/__mocks__/nanoid.js | 19 ++++ tests/endpoints/operation.test.ts | 153 ++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 tests/__mocks__/nanoid.js diff --git a/jest.config.ts b/jest.config.ts index d3e9c52..ddbbb72 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -10,6 +10,7 @@ export default { testMatch: ["**/tests/**/*.test.ts"], moduleNameMapper: { "^~/(.*)$": "/src/$1", + "nanoid": "/tests/__mocks__/nanoid.js", }, setupFilesAfterEnv: ["/tests/setup.ts"], }; diff --git a/tests/__mocks__/nanoid.js b/tests/__mocks__/nanoid.js new file mode 100644 index 0000000..d52c55c --- /dev/null +++ b/tests/__mocks__/nanoid.js @@ -0,0 +1,19 @@ +// Mock nanoid for Jest tests +let counter = 0; + +const nanoid = () => { + counter++; + return `MOCK${counter.toString().padStart(7, '0')}`; +}; + +const customAlphabet = (alphabet, size) => { + return () => { + counter++; + return `TEST${counter.toString().padStart(7, '0')}`; + }; +}; + +module.exports = { + nanoid, + customAlphabet, +}; \ No newline at end of file diff --git a/tests/endpoints/operation.test.ts b/tests/endpoints/operation.test.ts index 6482449..b39952c 100644 --- a/tests/endpoints/operation.test.ts +++ b/tests/endpoints/operation.test.ts @@ -2,13 +2,34 @@ import request from "supertest"; import dotenv from "dotenv"; import app from "~/server"; import Operation from "~/models/operation"; +import User from "~/models/user"; +import Task from "~/models/task"; import configDotenv from "~/config/dot-env"; dotenv.config(configDotenv); describe("Operation endpoints tests", () => { + let slackUser: any; + + beforeAll(async () => { + // Create a Slack user for testing PATCH operations + slackUser = new User({ + email: "slackuser@test.com", + password: "?testtest321!", + isVerified: true, + slackId: "U09DRSE6HDW", + profile: { + firstName: "Slack", + lastName: "User", + }, + }); + await slackUser.save(); + }); + afterAll(async () => { await Operation.deleteMany({}); + await Task.deleteMany({}); + await User.deleteOne({ email: "slackuser@test.com" }); }); describe("POST /operations", () => { @@ -167,4 +188,136 @@ describe("Operation endpoints tests", () => { expect(status).toBe(400); }); }); + + describe("PATCH /operations/:shortId", () => { + let testOperation: any; + + beforeEach(async () => { + // Create a pending operation for testing + testOperation = new Operation({ + user: slackUser._id, + source: "slack", + type: "bulk_create_tasks", + status: "pending", + payload: { + tasks: [ + { + description: "Test Task 1", + dueDate: "2025-07-18T14:55:37.403Z", + }, + { + description: "Test Task 2", + dueDate: "2025-07-18T14:55:37.403Z", + }, + ], + }, + metadata: { + channel: "D09D3PD3RB8", + approvedBy: null, + approvedAt: null, + }, + }); + await testOperation.save(); + }); + + afterEach(async () => { + await Operation.deleteMany({}); + await Task.deleteMany({}); + }); + + it("Should approve operation and create tasks", async () => { + const { status, body } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "approved" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(200); + expect(body).toHaveProperty("operation"); + expect(body.operation.status).toBe("approved"); + expect(body.operation.shortId).toBe(testOperation.shortId); + + // Verify tasks were created + const createdTasks = await Task.find({ user: slackUser._id }); + expect(createdTasks).toHaveLength(2); + expect(createdTasks[0].description).toBe("Test Task 1"); + expect(createdTasks[1].description).toBe("Test Task 2"); + }); + + it("Should reject operation without creating tasks", async () => { + const { status, body } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "rejected" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(200); + expect(body).toHaveProperty("operation"); + expect(body.operation.status).toBe("rejected"); + expect(body.operation.shortId).toBe(testOperation.shortId); + + // Verify no tasks were created + const createdTasks = await Task.find({ user: slackUser._id }); + expect(createdTasks).toHaveLength(0); + }); + + it("Should return 404 for non-existent operation", async () => { + const { status, body } = await request(app) + .patch("/api/operations/NONEXISTENT") + .send({ status: "approved" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(404); + expect(body.message).toBe("The requested operation doest not exist or has already been executed"); + }); + + it("Should return 404 for already processed operation", async () => { + // First approve the operation + await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "approved" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + // Try to approve again + const { status, body } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "rejected" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(404); + expect(body.message).toBe("The requested operation doest not exist or has already been executed"); + }); + + it("Should return 401 for missing API key", async () => { + const { status } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "approved" }) + .set("Accept", "application/json"); + + expect(status).toBe(401); + }); + + it("Should return 400 for invalid status", async () => { + const { status } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({ status: "invalid_status" }) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + + it("Should return 400 for missing status", async () => { + const { status } = await request(app) + .patch(`/api/operations/${testOperation.shortId}`) + .send({}) + .set("x-api-key", `${process.env.API_KEY}`) + .set("Accept", "application/json"); + + expect(status).toBe(400); + }); + }); }); From 7123dedb7bb58b6a3aa41a9e0953aeadc83454b5 Mon Sep 17 00:00:00 2001 From: HRulier Date: Thu, 22 Jan 2026 18:32:10 +0100 Subject: [PATCH 24/28] fix: POST /operations, fix find user --- src/controllers/operation.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/controllers/operation.controller.ts b/src/controllers/operation.controller.ts index 8da70d1..512bc39 100644 --- a/src/controllers/operation.controller.ts +++ b/src/controllers/operation.controller.ts @@ -10,7 +10,7 @@ import Operation from "~/models/operation"; import { ZodError } from "zod"; const NotFound = new NotFoundError( - "The requested operation doest not exist or has already been executed" + "The requested operation doest not exist or has already been executed", ); async function createOperation(req: Request, res: Response) { @@ -20,7 +20,7 @@ async function createOperation(req: Request, res: Response) { let userId: string | null = null; if (user && source === "slack") { - const slackUser = await User.findOne({ slackId: user.id }); + const slackUser = await User.findOne({ slackId: user }); userId = slackUser?._id || null; } @@ -100,7 +100,7 @@ async function updateAndExecuteOperation(req: Request, res: Response) { const updatedOperation = await Operation.findOneAndUpdate( { shortId, status: "pending" }, { status }, - { new: true } + { new: true }, ); return res.status(HTTP_STATUS.OK).json({ operation: updatedOperation }); From b098e2ed6ae8be459f53b32bd3549fddf43a9dc3 Mon Sep 17 00:00:00 2001 From: HRulier Date: Fri, 23 Jan 2026 10:45:30 +0100 Subject: [PATCH 25/28] feat(task): add support for priority (low, medium, high) --- src/controllers/tasks.controller.ts | 8 +++++--- src/models/task.ts | 7 ++++++- src/schemas/task.schema.ts | 4 +++- src/types/task.ts | 1 + tests/endpoints/tasks.test.ts | 20 ++++++++++++-------- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/controllers/tasks.controller.ts b/src/controllers/tasks.controller.ts index f84e784..fba402a 100644 --- a/src/controllers/tasks.controller.ts +++ b/src/controllers/tasks.controller.ts @@ -66,7 +66,7 @@ async function getTaskById(req: IAuthentificateRequest, res: Response) { const user = req.user as IUser; const { id } = req.params; const task = await Task.findOne({ _id: id, user: user._id }).populate( - populateTask + populateTask, ); if (!task) { @@ -83,7 +83,7 @@ async function createTask(req: IAuthentificateRequest, res: Response) { try { const user = req.user as IUser; const createData: CreateTaskInput = req.body; - let { position = 1024, dueDate, tags = [] } = createData; + let { position = 1024, priority, dueDate, tags = [] } = createData; if (position === 1024) { const minPositionTask = await Task.findOne({ @@ -98,6 +98,7 @@ async function createTask(req: IAuthentificateRequest, res: Response) { ...createData, user: user._id, position, + priority, tags, }); await task.save(); @@ -145,6 +146,7 @@ async function createTasks(req: Request, res: Response) { dueDate: task.dueDate, user: user, position: startingPosition - index, + priority: task.priority, tags: task.tags || [], }); }); @@ -171,7 +173,7 @@ async function updateTask(req: IAuthentificateRequest, res: Response) { updateData, { new: true, - } + }, ).populate(populateTask); if (!task) { diff --git a/src/models/task.ts b/src/models/task.ts index 4a906f8..622cda9 100755 --- a/src/models/task.ts +++ b/src/models/task.ts @@ -24,6 +24,11 @@ const TaskSchema = new Schema( type: Number, default: 1024, }, + priority: { + type: String, + default: "low", + enum: ["low", "medium", "high"], + }, user: { type: Schema.Types.ObjectId, ref: "User", @@ -42,7 +47,7 @@ const TaskSchema = new Schema( }, { timestamps: true, - } + }, ); export default mongoose.model("Task", TaskSchema); diff --git a/src/schemas/task.schema.ts b/src/schemas/task.schema.ts index d10c6a4..a357189 100644 --- a/src/schemas/task.schema.ts +++ b/src/schemas/task.schema.ts @@ -6,7 +6,7 @@ const utcDateSchema = z .string() .regex( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/, - "Date must be in UTC format (ISO 8601 with 'Z' suffix)" + "Date must be in UTC format (ISO 8601 with 'Z' suffix)", ) .refine((dateStr: string) => !isNaN(new Date(dateStr).getTime()), { message: "Date must be valid", @@ -24,6 +24,7 @@ const TaskSchema = z.object({ dueDate: z.coerce.date().openapi({ example: "2025-03-03T14:55:26.078Z" }), completed: z.boolean().default(false).openapi({ example: false }), position: z.number().default(1024).openapi({ example: 1024 }), + priority: z.string().default("low").openapi({ example: "low" }), user: z .string() .regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ID") @@ -49,6 +50,7 @@ const GetTasksQuerySchema = z.object({ const CreateTaskSchema = z.object({ dueDate: z.coerce.date().openapi({ example: "2025-03-03T14:55:37.403Z" }), position: z.number().nullish().openapi({ example: 1024 }), + priority: z.string().nullish().openapi({ example: "low" }), description: z.string().openapi({ example: "Lorem ipsum dolor sit amet" }), tags: z .array(z.string()) diff --git a/src/types/task.ts b/src/types/task.ts index 28d489a..a2deeb2 100755 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -23,6 +23,7 @@ export type TaskDocument = CreateTaskInput & { updatedAt?: Date; completed: boolean; position: number; + priority: string; user: IUser; tags: TaskDocument[]; }; diff --git a/tests/endpoints/tasks.test.ts b/tests/endpoints/tasks.test.ts index ba3196a..a1b5291 100644 --- a/tests/endpoints/tasks.test.ts +++ b/tests/endpoints/tasks.test.ts @@ -16,12 +16,14 @@ const getTestTasks = (userId: string) => [ { description: "Préparer la présentation pour la réunion client", dueDate: "2025-06-27T14:30:00.000+00:00", + priority: "high", completed: false, user: userId, }, { description: "Faire les courses pour le week-end", dueDate: "2025-07-01T18:45:00.000+00:00", + priority: "medium", completed: true, user: userId, }, @@ -115,7 +117,7 @@ describe("Tasks endpoints tests", () => { expect(status).toBe(200); expect(tasks.length).toBe(6); expect( - tasks.every((task: TaskDocument) => task.user === user._id.toString()) + tasks.every((task: TaskDocument) => task.user === user._id.toString()), ).toBe(true); }); @@ -146,26 +148,28 @@ describe("Tasks endpoints tests", () => { }); it("Should return planned tasks on day the 2025-07-03 for the authenticated user", async () => { - const date = "2025-07-03"; + const minDate = "2025-07-02T23:00:00.000Z"; + const maxDate = "2025-07-03T23:00:00.000Z"; const { status, body: { tasks }, } = await request(app) - .get(`/api/tasks?minDate=${date}&maxDate=${date}`) + .get(`/api/tasks?minDate=${minDate}&maxDate=${maxDate}`) .set("Authorization", `Bearer ${credentials.token}`); expect(status).toBe(200); expect(tasks.length).toBe(2); expect( tasks.every( - (task: TaskDocument) => format(task.dueDate, "yyyy-MM-dd") === date - ) + (task: TaskDocument) => + format(task.dueDate, "yyyy-MM-dd") === "2025-07-03", + ), ).toBe(true); }); it("Should return planned tasks between 2025-06-30 and 2025-07-06 for the authenticated user", async () => { - const minDate = "2025-06-30"; - const maxDate = "2025-07-06"; + const minDate = "2025-06-30T23:00:00.000Z"; + const maxDate = "2025-07-06T23:00:00.000Z"; const { status, body: { tasks }, @@ -320,7 +324,7 @@ describe("Tasks endpoints tests", () => { it("should return 401, unauthorized", async () => { const { status } = await request(app).delete( - `/api/tasks/${testTask._id.toString()}` + `/api/tasks/${testTask._id.toString()}`, ); expect(status).toBe(401); From b23716089785c33b053d00217e00dbb40f27fb01 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 7 Apr 2026 18:40:31 +0200 Subject: [PATCH 26/28] fix: set cors origin with env vars --- .gitignore | 4 +++- src/server.ts | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index cf4f355..1fd5e22 100755 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ node_modules/ dist/ .DS_Store structure.md -CLAUDE.md \ No newline at end of file +CLAUDE.md + +/docs/* \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 39a4010..22eaa94 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,10 +13,7 @@ const app = express(); app.use( cors({ - origin: - process.env.NODE_ENV === "production" - ? "https://todo-app.loopness.fr" - : ["http://localhost:5173", "http://192.168.1.58:5173"], + origin: process.env.FRONT_URL_CORS_ORIGIN, methods: ["PUT", "GET", "POST", "DELETE", "OPTIONS"], allowedHeaders: [ "Origin", @@ -27,7 +24,7 @@ app.use( "Access-Control-Allow-Credentials", ], credentials: true, - }) + }), ); app.set("trust proxy", 1); From eaf54b9b1c8240d95be7b9a22e7379cf6f1f0b72 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 7 Apr 2026 18:47:03 +0200 Subject: [PATCH 27/28] feat: revelant README.md and add description and author into package.json --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++----- package.json | 4 +-- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 07d45a1..4d7cba1 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,83 @@ -# Build and run the API container with host networking +# Todo API +RESTful API for a Todo application built with **Express.js**, **TypeScript**, and **MongoDB**. + +## Stack + +- **Runtime**: Node.js + TypeScript +- **Framework**: Express.js +- **Database**: MongoDB + Mongoose +- **Auth**: Passport.js — Local, JWT, Google OAuth2 +- **Validation**: Zod + OpenAPI auto-generation +- **Email**: React Email + Resend +- **Testing**: Jest + Supertest + +## Getting Started + +```bash +# Install dependencies +npm install + +# Copy and fill environment variables +cp .env.example .env + +# Start dev server +npm run dev +``` + +## Environment Variables + +| Variable | Description | +|---|---| +| `MONGO_URI` | MongoDB connection string | +| `JWT_SECRET` | JWT signing secret | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | +| `RESEND_API_KEY` | Resend email service key | +| `CORS_ORIGIN` | Allowed CORS origin | + +## API Endpoints + +| Prefix | Description | +|---|---| +| `/api/auth/*` | Register, login, OAuth, password reset | +| `/api/tasks/*` | Task CRUD with filtering & ordering | +| `/api/tags/*` | Tag CRUD with auto color assignment | +| `/api/operations/*` | Bulk task operations | +| `/api/jobs/*` | Scheduled jobs (daily reminders) | +| `/api-docs` | Swagger UI documentation | + +## Scripts + +```bash +npm run dev # Dev server with hot reload +npm run build # TypeScript build +npm start # Production server +npm test # Run tests +npm run test:coverage # Tests with coverage report +npm run email # Email template dev server +``` + +## Project Structure + +``` +src/ +├── controllers/ # HTTP handlers +├── services/ # Business logic +├── models/ # Mongoose schemas +├── routes/ # Express routes +├── middlewares/ # Auth, validation, rate limiting +├── schemas/ # Zod schemas +└── openapi/ # OpenAPI doc generation +``` + +## Docker + +```bash docker build -t todo-api --target development . +docker run --name todo-api --network host -v .:/app -v /app/node_modules todo-api +``` -docker run --name todo-api --network host \ - -v .:/app \ - -v /app/node_modules \ - todo-api +## Author -docker start todo-api -docker stop todo-api -docker logs -f todo-api +[HRulier](https://github.com/HRulier) diff --git a/package.json b/package.json index b2eea58..5ae9630 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,13 @@ "type": "git", "url": "git+https://github.com/HRulier/todo-api.git" }, - "author": "", + "author": "HRulier", "license": "ISC", "bugs": { "url": "https://github.com/HRulier/todo-api/issues" }, "homepage": "https://github.com/HRulier/todo-api#readme", - "description": "", + "description": "RESTful API for a Todo application — Express.js, TypeScript, MongoDB with JWT & OAuth2 authentication", "devDependencies": { "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", From 47d2937b39935f8448435daaca887b2be1ce0969 Mon Sep 17 00:00:00 2001 From: HRulier Date: Tue, 7 Apr 2026 18:51:37 +0200 Subject: [PATCH 28/28] fix(server): parse env vars for cors origin --- src/server.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 22eaa94..1e6bf5c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,9 +11,18 @@ dotenv.config(configDotenv); const app = express(); +const corsOrigin = (() => { + const raw = process.env.FRONT_URL_CORS_ORIGIN ?? ""; + try { + return JSON.parse(raw); + } catch { + return raw; + } +})(); + app.use( cors({ - origin: process.env.FRONT_URL_CORS_ORIGIN, + origin: corsOrigin, methods: ["PUT", "GET", "POST", "DELETE", "OPTIONS"], allowedHeaders: [ "Origin", @@ -24,7 +33,7 @@ app.use( "Access-Control-Allow-Credentials", ], credentials: true, - }), + }) ); app.set("trust proxy", 1);