diff --git a/config/config.ts b/config/config.ts index e594a218..1c95bcad 100644 --- a/config/config.ts +++ b/config/config.ts @@ -11,6 +11,8 @@ import { STAGING_RDS_TRACKING_CHANNEL_URL, RDS_STATUS_SITE_URL, RDS_STAGING_STATUS_SITE_URL, + RDS_DASHBOARD_SITE_URL, + RDS_STAGING_DASHBOARD_SITE_URL, } from "../src/constants/urls"; import { DISCORD_PROFILE_SERVICE_HELP_GROUP, @@ -26,6 +28,7 @@ const config = (env: env) => { TRACKING_CHANNEL_URL: RDS_TRACKING_CHANNEL_URL, PROFILE_SERVICE_HELP_GROUP_ID: DISCORD_PROFILE_SERVICE_HELP_GROUP, RDS_STATUS_SITE_URL: RDS_STATUS_SITE_URL, + DASHBOARD_SITE_URL: RDS_DASHBOARD_SITE_URL, }, staging: { RDS_BASE_API_URL: RDS_BASE_STAGING_API_URL, @@ -33,6 +36,7 @@ const config = (env: env) => { TRACKING_CHANNEL_URL: STAGING_RDS_TRACKING_CHANNEL_URL, PROFILE_SERVICE_HELP_GROUP_ID: DISCORD_PROFILE_SERVICE_STAGING_HELP_GROUP, RDS_STATUS_SITE_URL: RDS_STAGING_STATUS_SITE_URL, + DASHBOARD_SITE_URL: RDS_STAGING_DASHBOARD_SITE_URL, }, default: { RDS_BASE_API_URL: RDS_BASE_DEVELOPMENT_API_URL, @@ -41,6 +45,7 @@ const config = (env: env) => { PROFILE_SERVICE_HELP_GROUP_ID: DISCORD_PROFILE_SERVICE_DEVELOPMENT_HELP_GROUP, RDS_STATUS_SITE_URL: RDS_STATUS_SITE_URL, + DASHBOARD_SITE_URL: RDS_DASHBOARD_SITE_URL, }, }; diff --git a/src/constants/commands.ts b/src/constants/commands.ts index d2288db1..47a83d65 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -9,6 +9,25 @@ export const VERIFY = { "Generate a link with user specific token to link with RDS backend.", }; +export const GROUP_INVITE = { + name: "group-invite", + description: "Send group invite link for the user.", + options: [ + { + name: "name", + description: "User to send group invite link", + type: 6, + required: true, + }, + { + name: "role", + description: "Role you want to invite to the user", + type: 8, + required: true, + }, + ], +}; + export const MENTION_EACH = { name: "mention-each", description: "mention each user with this role", diff --git a/src/constants/responses.ts b/src/constants/responses.ts index bff6a2c0..92dd8e2f 100644 --- a/src/constants/responses.ts +++ b/src/constants/responses.ts @@ -65,6 +65,9 @@ export const FAILED_TO_FETCH_TASKS = `Failed to fetch tasks for **{{assignee}}** export const USER_NOT_FOUND = `User Not Found`; export const USER_STATUS_NOT_FOUND = "No Status Found"; +export const FAILED_TO_FETCH_DISCORD_GUILD_ROLE = + "Failed to fetch discord guild role"; + export const OVERDUE_DEFAULT_MESSAGE = "You have overdue tasks."; export const OVERDUE_CUSTOM_MESSAGE = "Please be aware that you currently have tasks that are overdue or due within the next {{days}} day. If you require additional time to complete these tasks, kindly submit an extension request."; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index e60a02d0..a86b355f 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -22,3 +22,7 @@ export const DEVELOPMENT_RDS_TRACKING_CHANNEL_URL = export const RDS_STATUS_SITE_URL = "https://status.realdevsquad.com"; export const RDS_STAGING_STATUS_SITE_URL = "https://staging-status.realdevsquad.com"; + +export const RDS_DASHBOARD_SITE_URL = "https://dashboard.realdevsquad.com"; +export const RDS_STAGING_DASHBOARD_SITE_URL = + "https://staging-dashboard.realdevsquad.com"; diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 96cb858f..bd3ad325 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -28,6 +28,7 @@ import { OOO, USER, REMOVE, + GROUP_INVITE, } from "../constants/commands"; import { updateNickName } from "../utils/updateNickname"; import { discordEphemeralResponse } from "../utils/discordEphemeralResponse"; @@ -42,6 +43,7 @@ import { } from "../constants/responses"; import { DevFlag } from "../typeDefinitions/filterUsersByRole"; import { kickEachUser } from "./kickEachUser"; +import { groupInvite } from "./groupInvite"; export async function baseHandler( message: discordMessageRequest, @@ -159,6 +161,12 @@ export async function baseHandler( ) as unknown as DevFlag; return await userCommand(data[0].value, env, dev); } + + case getCommandName(GROUP_INVITE): { + const data = message.data?.options as Array; + + return await groupInvite(data[0].value, data[1].value, env); + } default: { return commandNotFound(); } diff --git a/src/controllers/groupInvite.ts b/src/controllers/groupInvite.ts new file mode 100644 index 00000000..bb4c5cac --- /dev/null +++ b/src/controllers/groupInvite.ts @@ -0,0 +1,26 @@ +import config from "../../config/config"; +import { env } from "../typeDefinitions/default.types"; +import { discordTextResponse } from "../utils/discordResponse"; +import * as DiscordGroups from "../utils/fetchDiscordGroupById"; +import JSONResponse from "../utils/JsonResponse"; + +export async function groupInvite( + userId: string, + roleId: string, + env: env +): Promise { + const group = await DiscordGroups.fetchDiscordGroupById(roleId, env); + + if (!group.name.startsWith("group-")) { + return discordTextResponse(`<@&${roleId}> is not a valid group.`); + } + + const groupName = group.name.replace(/^group-/, "").replace(/-/g, " "); + const encodedGroupName = encodeURIComponent(groupName); + + return discordTextResponse( + `<@${userId}> join the group <@&${roleId}> via the link below:\n ${ + config(env).DASHBOARD_SITE_URL + }/groups/?dev=true&name=${encodedGroupName}` + ); +} diff --git a/src/index.ts b/src/index.ts index 63972db4..dba65a39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,10 +66,12 @@ router.get("/ankush", async (request, env, ctx: ExecutionContext) => { ctx.waitUntil(send(env)); const url = config(env).TRACKING_CHANNEL_URL; - - return new JSONResponse(`CURRENT_ENVIRONMENT: ${env.CURRENT_ENVIRONMENT}, tracking url - ${url}`, { status: 200 }); -}); + return new JSONResponse( + `CURRENT_ENVIRONMENT: ${env.CURRENT_ENVIRONMENT}, tracking url - ${url}`, + { status: 200 } + ); +}); router.post("/", async (request, env, ctx: ExecutionContext) => { const message: discordMessageRequest = await request.json(); diff --git a/src/register.ts b/src/register.ts index c7ca0263..6bd22737 100644 --- a/src/register.ts +++ b/src/register.ts @@ -9,6 +9,7 @@ import { OOO, USER, REMOVE, + GROUP_INVITE, } from "./constants/commands"; import { config } from "dotenv"; import { DISCORD_BASE_URL } from "./constants/urls"; @@ -39,6 +40,7 @@ async function registerGuildCommands( NOTIFY_OVERDUE, NOTIFY_ONBOARDING, REMOVE, + GROUP_INVITE, ]; try { diff --git a/src/typeDefinitions/default.types.d.ts b/src/typeDefinitions/default.types.d.ts index 3ae4f36b..b1828b20 100644 --- a/src/typeDefinitions/default.types.d.ts +++ b/src/typeDefinitions/default.types.d.ts @@ -7,6 +7,7 @@ export interface environment { } export interface variables { + DASHBOARD_SITE_URL: string; RDS_BASE_API_URL: string; VERIFICATION_SITE_URL: string; TRACKING_CHANNEL_URL: string; diff --git a/src/typeDefinitions/group.types.d.ts b/src/typeDefinitions/group.types.d.ts new file mode 100644 index 00000000..ba52bd91 --- /dev/null +++ b/src/typeDefinitions/group.types.d.ts @@ -0,0 +1,4 @@ +export type DiscordGuildRole = { + name: string; + id: string; +}; diff --git a/src/utils/fetchDiscordGroupById.ts b/src/utils/fetchDiscordGroupById.ts new file mode 100644 index 00000000..cf681e6c --- /dev/null +++ b/src/utils/fetchDiscordGroupById.ts @@ -0,0 +1,34 @@ +import { FAILED_TO_FETCH_DISCORD_GUILD_ROLE } from "../constants/responses"; +import { DISCORD_BASE_URL } from "../constants/urls"; +import { env } from "../typeDefinitions/default.types"; +import { DiscordGuildRole } from "../typeDefinitions/group.types"; +import createDiscordHeaders from "./createDiscordHeaders"; + +async function fetchDiscordGroupById( + roleId: string, + env: env +): Promise { + try { + const url = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/roles/${roleId}`; + const headers: HeadersInit = createDiscordHeaders({ + token: env.DISCORD_TOKEN, + }); + const options = { + method: "GET", + headers, + }; + const response = await fetch(url, options); + + if (!response.ok) { + throw new Error(FAILED_TO_FETCH_DISCORD_GUILD_ROLE); + } + + const responseData: DiscordGuildRole = await response.json(); + return responseData; + } catch (error) { + console.error("An error occurred while fetching discord groups:", error); + throw error; + } +} + +export { fetchDiscordGroupById }; diff --git a/src/utils/formatUserDetails.ts b/src/utils/formatUserDetails.ts index ce345959..de80aacd 100644 --- a/src/utils/formatUserDetails.ts +++ b/src/utils/formatUserDetails.ts @@ -1,6 +1,6 @@ import { UserResponseType } from "../typeDefinitions/rdsUser"; -export function convertTimeStamp(userDetails: UserResponseType) { +export function convertTimeStamp(userDetails: UserResponseType, flag: boolean) { const timestamp = userDetails.user?.discordJoinedAt; if (timestamp) { @@ -9,27 +9,55 @@ export function convertTimeStamp(userDetails: UserResponseType) { const day = String(date.getDate()).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); const year = date.getFullYear(); + if (flag) { + const monthName = date.toLocaleString("default", { month: "short" }); + const formattedDate = `${day} ${monthName}, ${year}`; + return formattedDate; + } const formattedDate = `${day}/${month}/${year}`; return formattedDate; } return "N/A"; } +export function convertEpochToDate(timestamp: number | undefined) { + if (timestamp) { + const date = new Date(timestamp); + + const day = String(date.getDate()).padStart(2, "0"); + const month = date.toLocaleString("default", { month: "short" }); + const year = date.getFullYear(); + const formattedDate = `${day} ${month}, ${year}`; + + return formattedDate; + } + + return "N/A"; +} export function formatUserDetails( userDetails: UserResponseType, flag: boolean ) { - const convertedTimestamp = convertTimeStamp(userDetails); - - const userId = `**User Id :** ${userDetails.user?.id}`; - const userName = `**User Name :** ${userDetails.user?.username}`; - const userFullName = `**Full Name :** ${userDetails.user?.first_name} ${userDetails.user?.last_name}`; - const userState = `**State :** ${userDetails.user?.state}`; - const discordJoinedAt = `**Joined Server on :** ${convertedTimestamp}`; + const convertedTimestamp = convertTimeStamp(userDetails, flag); + const accountCreationDate = convertEpochToDate(userDetails.user?.created_at); + + const userId = " ".repeat(30) + `**Id:** ${userDetails.user?.id}`; + const userName = + " ".repeat(13) + `**Username:** ${userDetails.user?.username}`; + const userFullName = `${ + flag ? " ".repeat(13) + "**Full Name:**" : "**Full Name :**" + } ${userDetails.user?.first_name} ${userDetails.user?.last_name}`; + const userState = `${flag ? " ".repeat(23) + "**State:**" : "**State :**"} ${ + userDetails.user?.state + }`; + const discordJoinedAt = `${ + flag ? " ".repeat(4) + "**Joined Discord:**" : "**Joined Server on :**" + } ${convertedTimestamp}`; + const accountCreated = `**Account Created:** ${accountCreationDate}`; if (!flag) return `## User Details\n${userFullName}\n${userState}\n${discordJoinedAt}`; - return `## User Details\n${userId}\n${userName}\n${userFullName}\n${userState}\n${discordJoinedAt}`; + return `## User Details\n${userName}\n${userState}\n\n${userFullName}\n${userId}\n\n${accountCreated}\n${discordJoinedAt}`; } diff --git a/tests/fixtures/groups.ts b/tests/fixtures/groups.ts new file mode 100644 index 00000000..40572528 --- /dev/null +++ b/tests/fixtures/groups.ts @@ -0,0 +1,11 @@ +import { DiscordGuildRole } from "../../src/typeDefinitions/group.types"; + +export const group: DiscordGuildRole = { + id: "1", + name: "group-frontend", +}; + +export const invalidGroup: DiscordGuildRole = { + id: "2", + name: "invalidRole", +}; diff --git a/tests/fixtures/user.ts b/tests/fixtures/user.ts index ee14d5e9..ed163ad7 100644 --- a/tests/fixtures/user.ts +++ b/tests/fixtures/user.ts @@ -2,6 +2,7 @@ export const user = { id: "iODXB6ns8jaZB9p0XlBw", incompleteUserDetails: false, discordJoinedAt: "2023-08-08T11:40:42.522000+00:00", + created_at: 1692748800000, discordId: "858838385330487336", github_display_name: "John Doe", updated_at: 1694888822719, @@ -178,6 +179,7 @@ export const userWithoutDiscordJoinedAt = { id: "DWcTUhbC5lRXfDjZRp06", incompleteUserDetails: false, discordJoinedAt: "", + created_at: 1692748800000, discordId: "504855562094247953", github_display_name: "John Doe", updated_at: 1694888822719, diff --git a/tests/unit/handlers/groupInvite.test.ts b/tests/unit/handlers/groupInvite.test.ts new file mode 100644 index 00000000..b9679fc6 --- /dev/null +++ b/tests/unit/handlers/groupInvite.test.ts @@ -0,0 +1,37 @@ +import { environment } from "../../fixtures/config"; +import * as DiscordGroups from "../../../src/utils/fetchDiscordGroupById"; +import JSONResponse from "../../../src/utils/JsonResponse"; +import { groupInvite } from "../../../src/controllers/groupInvite"; +import { group, invalidGroup } from "../../fixtures/groups"; +import { discordTextResponse } from "../../../src/utils/discordResponse"; + +describe("Test /group-invite command", () => { + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("Should be an instance of JSONResponse", async () => { + jest + .spyOn(DiscordGroups, "fetchDiscordGroupById") + .mockImplementation(() => Promise.resolve(group)); + + const response = await groupInvite("1", group.id, environment[0]); + + expect(response).toBeInstanceOf(JSONResponse); + }); + + it("Should return a discordTextResponse if group is not found", async () => { + jest + .spyOn(DiscordGroups, "fetchDiscordGroupById") + .mockImplementation(() => Promise.resolve(invalidGroup)); + + const expectedResponse = discordTextResponse( + `<@&${invalidGroup.id}> is not a valid group.` + ); + + const response = await groupInvite("1", invalidGroup.id, environment[0]); + + expect(await response.json()).toEqual(await expectedResponse.json()); + }); +}); diff --git a/tests/unit/utils/formatUserDetails.test.ts b/tests/unit/utils/formatUserDetails.test.ts index 35b26f2b..5863991c 100644 --- a/tests/unit/utils/formatUserDetails.test.ts +++ b/tests/unit/utils/formatUserDetails.test.ts @@ -4,7 +4,10 @@ import { userResponse, userWithoutDiscordJoinedAtResponse, } from "../../fixtures/user"; -import { convertTimeStamp } from "../../../src/utils/formatUserDetails"; +import { + convertTimeStamp, + convertEpochToDate, +} from "../../../src/utils/formatUserDetails"; describe("formatUserDetails function", () => { it("Should return a string", () => { @@ -16,15 +19,18 @@ describe("formatUserDetails function", () => { it("should format user details correctly in dev mode", () => { const formattedDetails = formatUserDetails(userResponse, true).trim(); - const userId = `**User Id :** iODXB6ns8jaZB9p0XlBw`; - const userName = `**User Name :** johndoe`; - const userFullName = `**Full Name :** John Doe`; - const userState = `**State :** ACTIVE`; - const discordJoinedAt = `**Joined Server on :** ${convertTimeStamp( - userResponse + const userId = " ".repeat(30) + `**Id:** iODXB6ns8jaZB9p0XlBw`; + const userName = " ".repeat(13) + `**Username:** johndoe`; + const userFullName = " ".repeat(13) + `**Full Name:** John Doe`; + const userState = " ".repeat(23) + `**State:** ACTIVE`; + const discordJoinedAt = + " ".repeat(4) + + `**Joined Discord:** ${convertTimeStamp(userResponse, true)}`; + const accountCreated = `**Account Created:** ${convertEpochToDate( + userResponse.user.created_at )}`; - const expectedFormattedDetails = `## User Details\n${userId}\n${userName}\n${userFullName}\n${userState}\n${discordJoinedAt}`; + const expectedFormattedDetails = `## User Details\n${userName}\n${userState}\n\n${userFullName}\n${userId}\n\n${accountCreated}\n${discordJoinedAt}`; expect(formattedDetails).toEqual(expectedFormattedDetails); }); @@ -34,7 +40,8 @@ describe("formatUserDetails function", () => { const userFullName = `**Full Name :** John Doe`; const userState = `**State :** ACTIVE`; const discordJoinedAt = `**Joined Server on :** ${convertTimeStamp( - userResponse + userResponse, + false )}`; const expectedFormattedDetails = `## User Details\n${userFullName}\n${userState}\n${discordJoinedAt}`; @@ -47,15 +54,21 @@ describe("formatUserDetails function", () => { true ).trim(); - const userId = `**User Id :** DWcTUhbC5lRXfDjZRp06`; - const userName = `**User Name :** johndoe`; - const userFullName = `**Full Name :** John Doe`; - const userState = `**State :** IDLE`; - const discordJoinedAt = `**Joined Server on :** ${convertTimeStamp( - userWithoutDiscordJoinedAtResponse + const userId = " ".repeat(30) + `**Id:** DWcTUhbC5lRXfDjZRp06`; + const userName = " ".repeat(13) + `**Username:** johndoe`; + const userFullName = " ".repeat(13) + `**Full Name:** John Doe`; + const userState = " ".repeat(23) + `**State:** IDLE`; + const discordJoinedAt = + " ".repeat(4) + + `**Joined Discord:** ${convertTimeStamp( + userWithoutDiscordJoinedAtResponse, + true + )}`; + const accountCreated = `**Account Created:** ${convertEpochToDate( + userResponse.user.created_at )}`; - const expectedFormattedDetails = `## User Details\n${userId}\n${userName}\n${userFullName}\n${userState}\n${discordJoinedAt}`; + const expectedFormattedDetails = `## User Details\n${userName}\n${userState}\n\n${userFullName}\n${userId}\n\n${accountCreated}\n${discordJoinedAt}`; expect(formattedDetails).toEqual(expectedFormattedDetails); }); @@ -68,7 +81,8 @@ describe("formatUserDetails function", () => { const userFullName = `**Full Name :** John Doe`; const userState = `**State :** IDLE`; const discordJoinedAt = `**Joined Server on :** ${convertTimeStamp( - userWithoutDiscordJoinedAtResponse + userWithoutDiscordJoinedAtResponse, + false )}`; const expectedFormattedDetails = `## User Details\n${userFullName}\n${userState}\n${discordJoinedAt}`;