이번 회사에서도 Flex(HR 근태관리솔루션)를 도입하게 되었다. 스타트업답게 슬랙은 당연히 사용하고 있었다.
지난번에 만든 것을 그대로 가져와서 슬랙을 이롭게 해볼까! 싶었지만 Flex의 로그인 시스템이 대대적인 개선이 이루어졌는지 기존 로그인 로직과 응답구조가 전체적으로 개편되어 있었다. 분석하는데 굉장히 애를 먹었지만
또한 현재 회사는 특별한 근무 구조를 가지고 있어, 13-17시만 고정근무하고 주간 40시간을 자유롭게 채우는 형태로 되어있어서 기존처럼 특정시간에만 알람을 줄 수 없었다. 그래서 알람을 한번만 보내는 것이 아닌 특정 시간마다 실행하여 매일 여러번 업데이트를 하게 만들었다. 규칙은 간단하게 아침 9시에 첫번째 슬랙을 보내고 30분 간격으로 업데이트를 하게 했다.
따로 Cron Job을 돌릴만한 컴퓨터가 없어서 Amazon EventBridge와 Lambda를 이용해 특정 시간에 기능을 실행할 수 있게 했다.
Amazon EventBridge는 Cron job schedule로 매일 오전 9시부터 30분 간격으로 Lambda를 실행한다.
0,30 0-14 ? * 2-6 *
Flex 로그인은
challenge
>identifier
>authentication
>password
>authorization
>customerUser
>exchange
를 거쳐Access Token
을 획득할 수 있게 되어있다. 기본적으로 Cookie에 내용을 담아 통신하는 형태로 보인다.
투자 많이 받고 보안이 까다로워진것 같다.부럽다... 우리 회사도 투자좀...
근무 형태에 따라
workStartRecordType
이라는 값이 달라지는데 API 문서가 없이 화면만으로 각각의 의미를 찾는데 좀 힘들었다.
또한 Flex API 응답이 단순하게 근무를 시작한 경우와 시작/끝을 등록한 경우를 다르게 처리해 까다롭게 처리해야 했다.
스케쥴은 시간에 따라 출근전/근무중/퇴근후 로 나뉘기 때문에 각각의 상태에 따라 시간을 잘 표시해줄 수 있게 처리해야 했다.
const axios = require("axios");
const AWS = require("aws-sdk");
const ACCESS_KEY_ID = "";
const SECRET_ACCESS_KEY = "";
const challengeURL = "https://flex.team/api-public/v2/auth/challenge";
const identifierURL = "https://flex.team/api-public/v2/auth/verification/identifier";
const authenticationURL = "https://flex.team/api-public/v2/auth/authentication";
const passwordURL = "https://flex.team/api-public/v2/auth/authentication/password";
const authorizationURL = "https://flex.team/api-public/v2/auth/authorization";
const customerUserURL = "https://flex.team/api-public/v2/auth/tokens/customer-user";
const exchangeURL = "https://flex.team/api-public/v2/auth/tokens/customer-user/exchange";
const workSchedulesURL = "https://flex.team/api/v2/time-tracking/users/work-schedules";
const customerIdHash = ""; // 회사 customerIdHash, Flex API를 디버깅해보면 본인 회사코드를 알수 있을 것이다. 10자리의 영숫자로 되어있다.
const searchUsersURL = `https://flex.team/action/v2/search/customers/${customerIdHash}/search-users`;
const slackPostMessageURL = "https://slack.com/api/chat.postMessage";
const slackUpdateMessageURL = "https://slack.com/api/chat.update";
AWS.config.update({
region: "ap-northeast-2",
credentials: {
accessKeyId: ACCESS_KEY_ID,
secretAccessKey: SECRET_ACCESS_KEY,
},
});
const Bucket = "Bucket-Name";
const Key = "daily-check-in.json";
const s3 = new AWS.S3({ params: { Bucket } });
const timetable = async () => {
const now = new Date();
const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
if (isWeekend) return; // 주말 예외 처리
const challenge = await axios
.post(challengeURL, {
deviceInfo: { os: "web", osVersion: "", appVersion: "" },
locationInfo: {},
})
.then(({ data }) => data);
// console.log(challenge.sessionId);
await axios
.post(
identifierURL,
{ identifier: "" }, // Login Email
{
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-login-session-id": challenge.sessionId,
},
}
)
.then(({ data }) => data);
// console.log(identifier);
const authentication = await axios
.get(authenticationURL, {
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-login-session-id": challenge.sessionId,
},
})
.then(({ data }) => data);
// console.log(authentication);
const password = await axios
.post(
passwordURL,
{ password: "" }, // Login Password
{
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-login-session-id": challenge.sessionId,
},
}
)
.then(({ data }) => data);
// console.log(password);
const authorization = await axios
.post(
authorizationURL,
{},
{
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-login-session-id": challenge.sessionId,
},
}
)
.then(({ data }) => data);
// console.log(authorization.v2Response.workspaceToken);
const customerUser = await axios
.get(customerUserURL, {
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-workspace-access": authorization.v2Response.workspaceToken.accessToken.token,
},
})
.then(({ data }) => data[0]);
// console.log(customerUser);
const exchange = await axios
.post(exchangeURL, customerUser, {
headers: {
cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
"flexteam-v2-workspace-access": authorization.v2Response.workspaceToken.accessToken.token,
},
})
.then(({ data }) => data);
const AID = exchange.token;
const today = new Date();
today.setHours(0);
today.setMinutes(0);
today.setSeconds(0);
today.setMilliseconds(0);
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// 전문연 등으로 인한 실제팀과 플랙스 팀이름이 약간 다른 경우를 위해
const getTeamName = (teamName) => {
switch (teamName) {
case "":
return "";
default:
return teamName;
}
};
const users = await axios
.post(
searchUsersURL + "?size=50",
{
filter: {
departmentIdHashes: [],
userStatuses: [
"LEAVE_OF_ABSENCE",
"LEAVE_OF_ABSENCE_SCHEDULED",
"RESIGNATION_SCHEDULED",
"IN_EMPLOY",
"IN_APPRENTICESHIP",
],
},
},
{ headers: { cookie: `AID=${AID};` } }
)
.then(({ data }) =>
data.list.map(({ user }) => ({
userIdHash: user.userIdHash,
name: user.name,
departmentName: getTeamName(user.positions[0].department.name),
}))
);
// console.log(users);
const userIdHashParam = users.map((user) => `userIdHashes=${user.userIdHash}`).join("&");
const workSchedules = await axios
.get(
workSchedulesURL +
`?${userIdHashParam}&timeStampFrom=${today.valueOf()}&timeStampTo=${tomorrow.valueOf()}`,
{ headers: { cookie: `AID=${AID};` } }
)
.then(({ data }) => {
const day = today.getDay() - 1; // 일월화수목금토 -> 월화수목금토일로 인덱스 수정, 토일은 위에서 예외처리 되었기때문에 -1만해도 괜찮다.
// console.log(data.workScheduleResults);
const workScheduleResults = data.workScheduleResults.map((workSchedule) => ({
userIdHash: workSchedule.userIdHash,
workType:
workSchedule.days[day].workRecords[workSchedule.days[day].workRecords?.length - 1 || 0]
?.name,
blockTimeFrom: workSchedule.days[day].workRecords[0]?.blockTimeFrom.timeStamp,
blockTimeTo:
workSchedule.days[day].workRecords[workSchedule.days[day].workRecords.length - 1]
?.blockTimeTo.timeStamp,
workStartRecordType:
workSchedule.days[day].workStartRecords[
workSchedule.days[day].workStartRecords?.length - 1 || 0
]?.customerWorkFormId,
workStartRecordFrom: workSchedule.days[day].workStartRecords[0]?.blockTimeFrom?.timeStamp,
timeOffType: workSchedule.days[day].timeOffs[0]?.timeOffRegisterUnit,
timeOffBlockTimeFrom: workSchedule.days[day].timeOffs[0]?.blockTimeFrom?.timeStamp,
timeOffBlockTimeTo: workSchedule.days[day].timeOffs[0]?.blockTimeTo?.timeStamp,
}));
return workScheduleResults.map((obj) => {
// workType 재정의
if (obj.timeOffType === "DAY") obj.workType = "휴가";
else if (
obj.timeOffType === "HALF_DAY_PM" && // 오후반차이고
obj.timeOffBlockTimeFrom <= now // 오후반차 시작시간보다 크면
)
obj.workType = "휴가";
else if (
obj.timeOffType === "HALF_DAY_AM" && // 오전반차이고
obj.timeOffBlockTimeFrom <= now && // 현재가 해당 시간이라면
now <= obj.timeOffBlockTimeTo
)
obj.workType = "휴가";
else if (obj.workStartRecordType === "85611") obj.workType = "근무";
else if (obj.workStartRecordType === "85613") obj.workType = "외근";
else if (obj.workStartRecordType === "85614") obj.workType = "원격 근무";
else if (obj.workStartRecordType === "85615") obj.workType = "출장";
// blockTimeFrom 재정의.
obj.blockTimeFrom = obj.blockTimeFrom || obj.workStartRecordFrom;
return obj;
});
});
// console.log(workSchedules);
const mergeUserInformation = users.reduce((acc, obj) => {
acc[obj.userIdHash] = obj;
return acc;
}, {});
workSchedules.forEach((workSchedule) => {
mergeUserInformation[workSchedule.userIdHash] = {
...mergeUserInformation[workSchedule.userIdHash],
...workSchedule,
};
});
// console.log(mergeUserInformation);
const groupByDepartment = Object.entries(mergeUserInformation).reduce((acc, [_, user]) => {
const key = user.departmentName;
if (!acc[key]) acc[key] = [];
acc[key].push(user);
return acc;
}, {});
// console.log(groupByDepartment);
const getEmoji = (workType) => {
switch (workType) {
case "근무":
return "office";
case "원격 근무":
return "heads-down";
case "외근":
return "taxi";
case "휴가":
return "beach_with_umbrella";
case "출장":
return "airplane";
}
};
const getTimeString = (type, from, to) => {
if (type === "휴가") return "";
else if (to === undefined)
return `\`${new Date(from).toLocaleTimeString("ko-KR", { timeZone: "asia/seoul" })} ~ \``;
else
return `\`${new Date(from).toLocaleTimeString("ko-KR", {
timeZone: "asia/seoul",
})} ~ ${new Date(to).toLocaleTimeString("ko-KR", { timeZone: "asia/seoul" })}\``;
};
const diffHour = (date1, date2) => {
const diff = new Date(date2).valueOf() - new Date(date1).valueOf();
const diffInHours = diff / 1000 / 60 / 60;
return diffInHours.toFixed(2);
};
let message = `>*${formatDate()} 데일리 체크인*\n:office: 사무실 :heads-down: 원격 근무 :taxi: 외근 :airplane: 출장 :beach_with_umbrella: 휴가\n`;
// 슬랙 메시지에 표시될 팀명 정렬을 위한 팀 이름 리스트
const departmentNames = [
"개발팀",
"디자인팀",
"아무개팀",
];
departmentNames.forEach((departmentName) => {
const users = groupByDepartment[departmentName];
if (!users) return;
if (users.filter((user) => user.workType !== undefined).length === 0) return;
message += `>${departmentName}\n`;
users
.filter((user) => user.workType !== undefined)
.forEach((user) => {
message += `:${getEmoji(user.workType)}: ${user.name}님 ${getTimeString(
user.workType,
user.blockTimeFrom,
user.blockTimeTo
)} ${
user.workType !== "휴가" && user.blockTimeTo
? `(Day: ${diffHour(user.blockTimeFrom, user.blockTimeTo)}h)`
: ""
} \n`;
});
message += "\n";
});
const response = await s3.getObject({ Bucket, Key }).promise();
const { ts, channel } = JSON.parse(response.Body?.toString("utf-8"));
const tsDate = new Date(ts * 1000);
const needPost = tsDate.getDate() !== now.getDate(); // 당일인지 체크 후 메세지를 보내거나 수정한다.
if (needPost) {
const slackMessage = await axios
.post(
slackPostMessageURL,
{ channel: "daily_check-in", text: message, invalid_charset: "UTF-8" },
{
headers: {
"Content-type": "application/json; charset=utf-8",
Authorization: "슬랙토큰",
},
}
)
.then(({ data }) => data);
await s3
.putObject({
Bucket,
Key,
Body: JSON.stringify({ ts: slackMessage.ts, channel: slackMessage.channel }),
CacheControl: "no-store",
})
.promise();
} else {
const slackMessage = await axios
.post(
slackUpdateMessageURL,
{ channel, text: message, invalid_charset: "UTF-8", ts },
{
headers: {
"Content-type": "application/json; charset=utf-8",
Authorization: "슬랙토큰",
},
}
)
.then(({ data }) => data);
}
};
function formatDate(date = new Date()) {
const d = date instanceof Date ? date : new Date();
let month = "" + (d.getMonth() + 1);
let day = "" + d.getDate();
const year = d.getFullYear();
if (month.length < 2) month = "0" + month;
if (day.length < 2) day = "0" + day;
return [year, month, day].join(".");
}
exports.handler = async (object) => {
await timetable();
let response = { statusCode: 200 };
return response;
};
코드가 너무 너무 길다...
플랙스 홈페이지 들어가면 로딩이 너무 느려서 사람들 데이터 보기가 너무 힘들었고 근무정보를 제대로 볼수가 없어서 불편했는데, 슬랙으로 편하게 볼 수 있어서 맘에 든다 :)
1빠