Skip to content

Commit a256841

Browse files
authored
feat: Add api to create onboarding extension request from discord server (#2307)
* fix:added onboarding type in request * feat: added types for onboarding extension request * feat: added validator and skip-authenticate middleware * fix: added missing field in user type * feat: added controller for handling the create onboarding extension request feature * fix: remove requestedBy field because validation for super-users is done before hitting this api * refactor: moved constant messages from controller * fix: remove super-users validation check as it is done before making this api call * fix: create newEndsOn from current date when deadline has missed * fix: wrap schema validation logic in try-catch block * chore: refactor varibale name for better readability * chore: refactor new deadline calculation logic in a separate utils file for reuse * chore: use utils function to calculate days to milliseconds * feat: added utils function to validate date * fix: return error response for invalid date * fix: return forbidden response for non-onboarding user * chore: added semicolon for consistent code practise * chore: added jsDoc for functions and refactor import statment * fix: import addLog from service file and fix lint issue
1 parent 8101a05 commit a256841

File tree

10 files changed

+292
-6
lines changed

10 files changed

+292
-6
lines changed

constants/requests.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const REQUEST_TYPE = {
1515
EXTENSION: "EXTENSION",
1616
TASK: "TASK",
1717
ALL: "ALL",
18+
ONBOARDING: "ONBOARDING",
1819
};
1920

2021
export const REQUEST_LOG_TYPE = {
@@ -53,3 +54,6 @@ export const TASK_REQUEST_MESSAGES = {
5354
ERROR_CREATING_TASK_REQUEST: "Error while creating task request",
5455
TASK_REQUEST_UPDATED_SUCCESS: "Task request updated successfully",
5556
};
57+
58+
export const ONBOARDING_REQUEST_CREATED_SUCCESSFULLY = "Onboarding extension request created successfully"
59+
export const UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST = "Only super user and onboarding user are authorized to create an onboarding extension request"

controllers/onboardingExtension.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import {
2+
ERROR_WHILE_CREATING_REQUEST,
3+
LOG_ACTION,
4+
ONBOARDING_REQUEST_CREATED_SUCCESSFULLY,
5+
REQUEST_ALREADY_PENDING,
6+
REQUEST_LOG_TYPE,
7+
REQUEST_STATE,
8+
REQUEST_TYPE,
9+
UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST,
10+
} from "../constants/requests";
11+
import { userState } from "../constants/userStatus";
12+
import { addLog } from "../services/logService";
13+
import { createRequest, getRequestByKeyValues } from "../models/requests";
14+
import { fetchUser } from "../models/users";
15+
import { getUserStatus } from "../models/userStatus";
16+
import { User } from "../typeDefinitions/users";
17+
import {
18+
CreateOnboardingExtensionBody,
19+
OnboardingExtension,
20+
OnboardingExtensionCreateRequest,
21+
OnboardingExtensionResponse
22+
} from "../types/onboardingExtension";
23+
import { convertDateStringToMilliseconds, getNewDeadline } from "../utils/requests";
24+
import { convertDaysToMilliseconds } from "../utils/time";
25+
26+
/**
27+
* Controller to handle the creation of onboarding extension requests.
28+
*
29+
* This function processes the request to create an extension for the onboarding period,
30+
* validates the user status, checks existing requests, calculates new deadlines,
31+
* and stores the new request in the database with logging.
32+
*
33+
* @param {OnboardingExtensionCreateRequest} req - The Express request object containing the body with extension details.
34+
* @param {OnboardingExtensionResponse} res - The Express response object used to send back the response.
35+
* @returns {Promise<OnboardingExtensionResponse>} Resolves to a response with the status and data or an error message.
36+
*/
37+
export const createOnboardingExtensionRequestController = async (req: OnboardingExtensionCreateRequest, res: OnboardingExtensionResponse): Promise<OnboardingExtensionResponse> => {
38+
try {
39+
40+
const data = req.body as CreateOnboardingExtensionBody;
41+
const {user, userExists} = await fetchUser({discordId: data.userId});
42+
43+
if(!userExists) {
44+
return res.boom.notFound("User not found");
45+
}
46+
47+
const { id: userId, discordJoinedAt, username} = user as User;
48+
const { data: userStatus } = await getUserStatus(userId);
49+
50+
if(!userStatus || userStatus.currentStatus.state != userState.ONBOARDING){
51+
return res.boom.forbidden(UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST);
52+
}
53+
54+
const latestExtensionRequest: OnboardingExtension = await getRequestByKeyValues({
55+
userId: userId,
56+
type: REQUEST_TYPE.ONBOARDING
57+
});
58+
59+
if(latestExtensionRequest && latestExtensionRequest.state === REQUEST_STATE.PENDING){
60+
return res.boom.badRequest(REQUEST_ALREADY_PENDING);
61+
}
62+
63+
const millisecondsInThirtyOneDays = convertDaysToMilliseconds(31);
64+
const numberOfDaysInMillisecond = convertDaysToMilliseconds(data.numberOfDays);
65+
const { isDate, milliseconds: discordJoinedDateInMillisecond } = convertDateStringToMilliseconds(discordJoinedAt);
66+
67+
if(!isDate){
68+
logger.error(ERROR_WHILE_CREATING_REQUEST, "Invalid date");
69+
return res.boom.badImplementation(ERROR_WHILE_CREATING_REQUEST);
70+
}
71+
72+
let requestNumber: number;
73+
let oldEndsOn: number;
74+
const currentDate = Date.now();
75+
76+
if(!latestExtensionRequest){
77+
requestNumber = 1;
78+
oldEndsOn = discordJoinedDateInMillisecond + millisecondsInThirtyOneDays;
79+
}else if(latestExtensionRequest.state === REQUEST_STATE.REJECTED) {
80+
requestNumber = latestExtensionRequest.requestNumber + 1;
81+
oldEndsOn = latestExtensionRequest.oldEndsOn;
82+
}else{
83+
requestNumber = latestExtensionRequest.requestNumber + 1;
84+
oldEndsOn = latestExtensionRequest.newEndsOn;
85+
}
86+
87+
const newEndsOn = getNewDeadline(currentDate, oldEndsOn, numberOfDaysInMillisecond);
88+
89+
const onboardingExtension = await createRequest({
90+
type: REQUEST_TYPE.ONBOARDING,
91+
state: REQUEST_STATE.PENDING,
92+
userId: userId,
93+
requestedBy: username,
94+
oldEndsOn: oldEndsOn,
95+
newEndsOn: newEndsOn,
96+
reason: data.reason,
97+
requestNumber: requestNumber,
98+
});
99+
100+
const onboardingExtensionLog = {
101+
type: REQUEST_LOG_TYPE.REQUEST_CREATED,
102+
meta: {
103+
requestId: onboardingExtension.id,
104+
action: LOG_ACTION.CREATE,
105+
userId: userId,
106+
createdAt: Date.now(),
107+
},
108+
body: onboardingExtension,
109+
};
110+
111+
await addLog(onboardingExtensionLog.type, onboardingExtensionLog.meta, onboardingExtensionLog.body);
112+
113+
return res.status(201).json({
114+
message: ONBOARDING_REQUEST_CREATED_SUCCESSFULLY,
115+
data: {
116+
id: onboardingExtension.id,
117+
...onboardingExtension,
118+
}
119+
});
120+
}catch (err) {
121+
logger.error(ERROR_WHILE_CREATING_REQUEST, err);
122+
return res.boom.badImplementation(ERROR_WHILE_CREATING_REQUEST);
123+
}
124+
};

controllers/requests.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import { createTaskExtensionRequest, updateTaskExtensionRequest } from "./extens
1313
import { UpdateRequest } from "../types/requests";
1414
import { TaskRequestRequest } from "../types/taskRequests";
1515
import { createTaskRequestController } from "./taskRequestsv2";
16+
import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../types/onboardingExtension";
17+
import { createOnboardingExtensionRequestController } from "./onboardingExtension";
1618

1719
export const createRequestController = async (
18-
req: OooRequestCreateRequest | ExtensionRequestRequest | TaskRequestRequest,
20+
req: OooRequestCreateRequest | ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest,
1921
res: CustomResponse
2022
) => {
2123
const type = req.body.type;
@@ -26,6 +28,8 @@ export const createRequestController = async (
2628
return await createTaskExtensionRequest(req as ExtensionRequestRequest, res as ExtensionRequestResponse);
2729
case REQUEST_TYPE.TASK:
2830
return await createTaskRequestController(req as TaskRequestRequest, res as CustomResponse);
31+
case REQUEST_TYPE.ONBOARDING:
32+
return await createOnboardingExtensionRequestController(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse);
2933
default:
3034
return res.boom.badRequest("Invalid request type");
3135
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextFunction, Request, Response } from "express"
2+
import { REQUEST_TYPE } from "../constants/requests";
3+
/**
4+
* Middleware to selectively authenticate or verify Discord bot based on the request type.
5+
* Specifically handles requests for onboarding extensions by skipping authentication.
6+
*
7+
* @param {Function} authenticate - The authentication middleware to apply for general requests.
8+
* @param {Function} verifyDiscordBot - The middleware to verify requests from a Discord bot.
9+
* @returns {Function} A middleware function that processes the request based on its type.
10+
*
11+
* @example
12+
* app.use(skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot));
13+
*/
14+
export const skipAuthenticateForOnboardingExtensionRequest = (authenticate, verifyDiscordBot) => {
15+
return async (req: Request, res: Response, next: NextFunction) => {
16+
const type = req.body.type;
17+
const dev = req.query.dev;
18+
19+
if(type === REQUEST_TYPE.ONBOARDING){
20+
if (dev != "true"){
21+
return res.status(501).json({
22+
message: "Feature not implemented"
23+
})
24+
}
25+
return await verifyDiscordBot(req, res, next);
26+
}
27+
28+
return await authenticate(req, res, next)
29+
}
30+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import joi from "joi";
2+
import { NextFunction } from "express";
3+
import { REQUEST_TYPE } from "../../constants/requests";
4+
import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../../types/onboardingExtension";
5+
6+
export const createOnboardingExtensionRequestValidator = async (
7+
req: OnboardingExtensionCreateRequest,
8+
_res: OnboardingExtensionResponse,
9+
_next: NextFunction
10+
) => {
11+
12+
const schema = joi
13+
.object()
14+
.strict()
15+
.keys({
16+
numberOfDays: joi.number().required().positive().integer().min(1).messages({
17+
"number.base": "numberOfDays must be a number",
18+
"any.required": "numberOfDays is required",
19+
"number.positive": "numberOfDays must be positive",
20+
"number.min": "numberOfDays must be greater than zero",
21+
"number.integer": "numberOfDays must be a integer"
22+
}),
23+
reason: joi.string().required().messages({
24+
"string.empty": "reason cannot be empty",
25+
"any.required": "reason is required",
26+
}),
27+
type: joi.string().valid(REQUEST_TYPE.ONBOARDING).required().messages({
28+
"string.empty": "type cannot be empty",
29+
"any.required": "type is required",
30+
}),
31+
userId: joi.string().required().messages({
32+
"string.empty": "userId cannot be empty",
33+
"any.required": "userId is required"
34+
})
35+
});
36+
try{
37+
await schema.validateAsync(req.body, { abortEarly: false });
38+
}catch(error){
39+
logger.error(`Error while validating request payload`, error);
40+
throw error;
41+
}
42+
};

middlewares/validators/requests.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { ExtensionRequestRequest, ExtensionRequestResponse } from "../../types/e
99
import { CustomResponse } from "../../typeDefinitions/global";
1010
import { UpdateRequest } from "../../types/requests";
1111
import { TaskRequestRequest, TaskRequestResponse } from "../../types/taskRequests";
12+
import { createOnboardingExtensionRequestValidator } from "./onboardingExtensionRequest";
13+
import { OnboardingExtensionCreateRequest, OnboardingExtensionResponse } from "../../types/onboardingExtension";
1214

1315
export const createRequestsMiddleware = async (
14-
req: OooRequestCreateRequest|ExtensionRequestRequest | TaskRequestRequest,
16+
req: OooRequestCreateRequest|ExtensionRequestRequest | TaskRequestRequest | OnboardingExtensionCreateRequest,
1517
res: CustomResponse,
1618
next: NextFunction
1719
) => {
@@ -28,6 +30,9 @@ export const createRequestsMiddleware = async (
2830
case REQUEST_TYPE.TASK:
2931
await createTaskRequestValidator(req as TaskRequestRequest, res as TaskRequestResponse, next);
3032
break;
33+
case REQUEST_TYPE.ONBOARDING:
34+
await createOnboardingExtensionRequestValidator(req as OnboardingExtensionCreateRequest, res as OnboardingExtensionResponse, next);
35+
break;
3136
default:
3237
res.boom.badRequest(`Invalid request type: ${type}`);
3338
}
@@ -36,7 +41,7 @@ export const createRequestsMiddleware = async (
3641
} catch (error) {
3742
const errorMessages = error.details.map((detail:any) => detail.message);
3843
logger.error(`Error while validating request payload : ${errorMessages}`);
39-
res.boom.badRequest(errorMessages);
44+
return res.boom.badRequest(errorMessages);
4045
}
4146
};
4247

routes/requests.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ const { SUPERUSER } = require("../constants/roles");
66
import authenticate from "../middlewares/authenticate";
77
import { createRequestsMiddleware,updateRequestsMiddleware,getRequestsMiddleware } from "../middlewares/validators/requests";
88
import { createRequestController , updateRequestController, getRequestsController} from "../controllers/requests";
9+
import { skipAuthenticateForOnboardingExtensionRequest } from "../middlewares/skipAuthenticateForOnboardingExtension";
10+
import { verifyDiscordBot } from "../middlewares/authorizeBot";
911

1012
router.get("/", getRequestsMiddleware, getRequestsController);
11-
router.post("/",authenticate, createRequestsMiddleware, createRequestController);
13+
router.post("/", skipAuthenticateForOnboardingExtensionRequest(authenticate, verifyDiscordBot), createRequestsMiddleware, createRequestController);
1214
router.put("/:id",authenticate, authorizeRoles([SUPERUSER]), updateRequestsMiddleware, updateRequestController);
1315
module.exports = router;

typeDefinitions/users.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type User = {
2+
id?: string
23
username?: string;
34
first_name?: string;
45
last_name?: string;
@@ -17,7 +18,8 @@ export type User = {
1718
roles?: {
1819
member?: boolean;
1920
in_discord?: boolean;
20-
};
21+
super_user?: boolean;
22+
}
2123
tokens?: {
2224
githubAccessToken?: string;
2325
};
@@ -29,4 +31,4 @@ export type User = {
2931
};
3032
incompleteUserDetails?: boolean;
3133
nickname_synced?: boolean;
32-
};
34+
};

types/onboardingExtension.d.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Request, Response } from "express";
2+
import { Boom } from "express-boom";
3+
import { REQUEST_STATE, REQUEST_TYPE } from "../constants/requests";
4+
import { RequestQuery } from "./requests";
5+
6+
export type OnboardingExtension = {
7+
id: string;
8+
type: REQUEST_TYPE.ONBOARDING;
9+
oldEndsOn: number;
10+
newEndsOn: number;
11+
message?: string;
12+
reason: string;
13+
requestedBy: string;
14+
state: REQUEST_STATE;
15+
lastModifiedBy?: string;
16+
createdAt: Timestamp;
17+
updatedAt: Timestamp;
18+
requestNumber: number;
19+
userId: string;
20+
}
21+
22+
export type CreateOnboardingExtensionBody = {
23+
type: string;
24+
numberOfDays: number;
25+
userId: string;
26+
reason: string;
27+
}
28+
29+
export type OnboardingExtensionRequestQuery = RequestQuery & {
30+
dev?: string
31+
}
32+
33+
export type OnboardingExtensionResponse = Response & {
34+
boom: Boom
35+
}
36+
export type OnboardingExtensionCreateRequest = Request & {
37+
body: CreateOnboardingExtensionBody;
38+
query: OnboardingExtensionRequestQuery;
39+
}

utils/requests.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Calculates the new deadline based on the current date, the old end date, and the additional duration in milliseconds.
3+
*
4+
* @param {number} currentDate - The current date as a timestamp in milliseconds.
5+
* @param {number} oldEndsOn - The previous end date as a timestamp in milliseconds.
6+
* @param {number} numberOfDaysInMillisecond - The duration to extend the deadline, in milliseconds.
7+
* @returns {number} The new deadline as a timestamp in milliseconds.
8+
*/
9+
export const getNewDeadline = (currentDate: number, oldEndsOn: number, numberOfDaysInMillisecond: number): number => {
10+
if (currentDate > oldEndsOn) {
11+
return currentDate + numberOfDaysInMillisecond;
12+
}
13+
return oldEndsOn + numberOfDaysInMillisecond;
14+
};
15+
16+
/**
17+
* Converts a date string into a timestamp in milliseconds.
18+
* Validates whether the provided string is a valid date format.
19+
*
20+
* @param {string} date - The date string to convert (e.g., "2024-10-17T16:10:52.668Z").
21+
* @returns {{ isDate: boolean, milliseconds?: number }} An object indicating validity and the timestamp if valid.
22+
*/
23+
export const convertDateStringToMilliseconds = (date: string): { isDate: boolean; milliseconds?: number; } => {
24+
const milliseconds = Date.parse(date);
25+
if (!milliseconds) {
26+
return {
27+
isDate: false,
28+
};
29+
}
30+
return {
31+
isDate: true,
32+
milliseconds,
33+
};
34+
};

0 commit comments

Comments
 (0)