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/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/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/package-lock.json b/package-lock.json index 8f041eb..e1b64f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,11 +22,13 @@ "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", "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", @@ -7909,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", @@ -7921,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": { @@ -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", @@ -8649,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 4b971e4..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", @@ -64,11 +64,13 @@ "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", "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 04d8e45..1e947b8 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 = { @@ -145,6 +147,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 @@ -156,6 +159,76 @@ 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: `${process.env.API_URL}/auth/slack/callback`, + passReqToCallback: true, + scope: ["identity.basic", "email"], + }, + 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 b12e238..e2309ff 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(); @@ -118,15 +120,20 @@ async function loginWithGoogle( ) { try { const redirectUrl = (req.query.redirectUrl || "") as string; + const timezone = (req.query.timezone || "") as string; 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"); @@ -188,9 +195,85 @@ async function loginWithGoogleCallback( queryParams += `&redirectUrl=${stateData.redirectUrl}`; } - return res.redirect( - `${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-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); } @@ -492,11 +575,30 @@ 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; + 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, loginWithGoogle, loginWithGoogleCallback, + loginWithSlack, + loginWithSlackCallback, refresh, logout, forgotPassword, @@ -507,6 +609,7 @@ const AuthController: IAuthController = { resendVerificationEmail, getProfile, updateProfile, + getUserIdFromSlackId, deleteUser, }; diff --git a/src/controllers/operation.controller.ts b/src/controllers/operation.controller.ts new file mode 100644 index 0000000..512bc39 --- /dev/null +++ b/src/controllers/operation.controller.ts @@ -0,0 +1,117 @@ +import { Response, Request } from "express"; +import HTTP_STATUS from "~/utils/http_status"; +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; + + let userId: string | null = null; + + if (user && source === "slack") { + const slackUser = await User.findOne({ slackId: user }); + 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 || {}, + }); + + // Validate tasks before saving + 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); + } +} + +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/controllers/tasks.controller.ts b/src/controllers/tasks.controller.ts index ed01349..fba402a 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"; @@ -65,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) { @@ -82,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({ @@ -97,6 +98,7 @@ async function createTask(req: IAuthentificateRequest, res: Response) { ...createData, user: user._id, position, + priority, tags, }); await task.save(); @@ -109,6 +111,57 @@ 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, + priority: task.priority, + 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; @@ -120,7 +173,7 @@ async function updateTask(req: IAuthentificateRequest, res: Response) { updateData, { new: true, - } + }, ).populate(populateTask); if (!task) { @@ -153,6 +206,7 @@ const TaskController: ITaskController = { getTasks, getTaskById, createTask, + createTasks, updateTask, deleteTask, }; diff --git a/src/models/operation.ts b/src/models/operation.ts new file mode 100644 index 0000000..7e64098 --- /dev/null +++ b/src/models/operation.ts @@ -0,0 +1,89 @@ +import mongoose from "mongoose"; +import { customAlphabet } from "nanoid"; +import { OperationDocument } from "~/types/operation"; +const Schema = mongoose.Schema; + +const OperationSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: "User", + required: true, + }, + shortId: { + type: String, + }, + 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, + } +); + +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/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/models/user.ts b/src/models/user.ts index 3f00236..6a00b89 100755 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -26,10 +26,19 @@ const UserSchema = new Schema( required: true, default: "Member", }, + timezone: { + type: String, + required: true, + default: "Europe/Paris", + }, googleId: { type: Number, default: null, }, + slackId: { + type: String, + default: null, + }, isVerified: { type: Boolean, required: true, 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/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/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/auth.paths.ts b/src/openapi/paths/auth.paths.ts index b6d2c79..0a47db3 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 { SlackIdSchema } from "~/schemas/id.schema"; import { unauthorizedResponse, internalServerResponse, @@ -483,6 +484,35 @@ export const registerAuthPaths = () => { }, }); + registry.registerPath({ + method: "get", + 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: SlackIdSchema, + }, + 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/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/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/auth.routes.ts b/src/routes/auth.routes.ts index 48e3fde..4dd338c 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 { SlackIdSchema } from "~/schemas/id.schema"; const authRoutes = Router(); @@ -30,6 +33,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({ @@ -98,6 +106,15 @@ authRoutes.put( AuthController.updateProfile ); +authRoutes.get( + "/slack/user/:slackId", + validateRequest({ + params: SlackIdSchema, + }), + verifyApiKey, + AuthController.getUserIdFromSlackId +); + authRoutes.delete("/account", requireAuth, AuthController.deleteUser); export default authRoutes; 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..7edbc8f --- /dev/null +++ b/src/routes/operation.routes.ts @@ -0,0 +1,26 @@ +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"; +import { CreateOperationSchema } from "~/schemas/operation.schema"; + +const operationRoutes = Router(); + +operationRoutes.post( + "/", + verifyApiKey, + validateRequest({ body: CreateOperationSchema }), + 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/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/id.schema.ts b/src/schemas/id.schema.ts index 4f65fd4..2848658 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-zA-Z]{11}$/, "Invalid Slack ID"), +}); + export default IdSchema; diff --git a/src/schemas/operation.schema.ts b/src/schemas/operation.schema.ts new file mode 100644 index 0000000..395dc65 --- /dev/null +++ b/src/schemas/operation.schema.ts @@ -0,0 +1,125 @@ +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", + }), + shortId: z.string().openapi({ + example: "MFAFU856DVV", + }), + 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/schemas/task.schema.ts b/src/schemas/task.schema.ts index 8b4a59e..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()) @@ -58,6 +60,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 +76,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/schemas/user.schema.ts b/src/schemas/user.schema.ts index 009067c..e3207f8 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -9,7 +9,9 @@ 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 }), + 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" }), @@ -52,6 +54,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/server.ts b/src/server.ts index 9e04e77..1e6bf5c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,12 +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.NODE_ENV === "production" - ? "https://todo-app.loopness.fr" - : ["http://localhost:5173", "http://192.168.1.58:5173"], + origin: corsOrigin, methods: ["PUT", "GET", "POST", "DELETE", "OPTIONS"], allowedHeaders: [ "Origin", @@ -30,6 +36,8 @@ app.use( }) ); +app.set("trust proxy", 1); + app.use(express.json()); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); 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/services/users.services.ts b/src/services/users.services.ts index d814f16..8291291 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"; @@ -29,23 +28,27 @@ 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, + googleId: userData?.googleId, + slackId: userData?.slackId, profile: userData.profile || {}, + timezone: userData.timezone, 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 +58,16 @@ const findOrCreateUser = async (userData: Partial) => { }); refreshToken = generateRefreshToken(userInfo); - await 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(); } @@ -71,34 +83,77 @@ 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 tasks = await Task.find({ - user: { $in: users.map((user: any) => user._id) }, - completed: false, - dueDate: { - $gte: startOfDay(today), - $lte: endOfDay(today), - }, - }).populate([ - { - path: "tags", - select: "_id label color", - }, - { - path: "user", - select: "_id email profile", - }, - ]); + const timezones = [...new Set(users.map((user) => user.timezone))]; + + const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + 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; + }; + + 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()]; @@ -115,7 +170,7 @@ const sendDailyEmailToUsers = async () => { 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}>`, diff --git a/src/types/auth.ts b/src/types/auth.ts index badd797..04e8ccb 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; @@ -25,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; } diff --git a/src/types/operation.ts b/src/types/operation.ts new file mode 100644 index 0000000..a7c68c0 --- /dev/null +++ b/src/types/operation.ts @@ -0,0 +1,10 @@ +import z from "~/utils/zod/zod-extended"; +import { Response, Request } from "express"; +import { OperationSchema } from "~/schemas/operation.schema"; + +export type OperationDocument = z.infer; + +export interface IOperationController { + createOperation: (req: Request, res: Response) => void; + updateAndExecuteOperation: (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/task.ts b/src/types/task.ts index 96e4402..a2deeb2 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 @@ -16,6 +23,7 @@ export type TaskDocument = CreateTaskInput & { updatedAt?: Date; completed: boolean; position: number; + priority: string; user: IUser; tags: TaskDocument[]; }; @@ -24,6 +32,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; } diff --git a/src/types/users.ts b/src/types/users.ts index a05f1f4..749bcb8 100755 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -10,7 +10,9 @@ export interface IUser extends Document { email: string; profile: UserProfile; role: string; + timezone: string; googleId: number | null; + slackId: string | null; isVerified: boolean; dailyEmailReminder: boolean; password: string | null; 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 new file mode 100644 index 0000000..b39952c --- /dev/null +++ b/tests/endpoints/operation.test.ts @@ -0,0 +1,323 @@ +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", () => { + 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); + }); + }); + + 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); + }); + }); +}); 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);