Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#527 회원 탈퇴 구현 #528

Open
wants to merge 25 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c4ce04b
Add: /users/delete endpoint
kmc7468 Jul 23, 2024
dd6d7c1
Add: personal information delete cronjob
kmc7468 Jul 23, 2024
f11f158
Refactor: rename withdraw-related endpoint/schema
kmc7468 Jul 27, 2024
3e87619
Add: restore withdraw field of userSchema
kmc7468 Jul 27, 2024
e7926e5
Refactor: check if user withdrew when populating chat and user documents
kmc7468 Jul 28, 2024
996b9ee
Refactor: use userOid instead of userId
kmc7468 Jul 30, 2024
c340df9
Refactor: check if user withdrew when populating documents
kmc7468 Aug 12, 2024
670eae6
Refactor: now remove FCM device tokens when withdrawing
kmc7468 Aug 20, 2024
00ddb69
Docs: withdraw endpoint swagger
kmc7468 Aug 21, 2024
7c596a9
Add: authorIsWithdraw field
kmc7468 Oct 8, 2024
d9ed57b
Fix: chat.authorId can be nullish value
kmc7468 Nov 5, 2024
826ee1a
Fix: withdrew user populate error
kmc7468 Nov 5, 2024
69127d5
Add: withdraw field in room/report related responses
kmc7468 Nov 5, 2024
423571b
Merge branch 'dev' into #527-account-delete
kmc7468 Nov 29, 2024
3af8bf7
Refactor: compile speed & ts-node startup speed optmization
kmc7468 Nov 29, 2024
372ecab
Fix: test error
kmc7468 Nov 29, 2024
f58f645
Add: withdraw check into items.js
kmc7468 Nov 29, 2024
069241b
Refactor: use sid in responseTimeMiddleware
kmc7468 Dec 3, 2024
e54cca3
Add: migration script that converts userId to userOid in chats
kmc7468 Jan 21, 2025
1338fcc
Refactor: getIsOver function now uses userOid instead of userId
kmc7468 Jan 21, 2025
0f7c9dd
Fix: sample generator inserts userId into chat.content
kmc7468 Jan 21, 2025
d290945
Fix: isOver is always false
kmc7468 Jan 21, 2025
aff0f86
Add: rejoin restriction after user withdrawal
kmc7468 Jan 22, 2025
4d63ff8
Refactor: ensure user is logged out immediately upon withdrawal
kmc7468 Jan 22, 2025
7665b97
Fix: docs and validator for withdraw endpoint
kmc7468 Jan 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/lottery/services/globalState.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const checkIsUserEligible = (user) => {
const getUserGlobalStateHandler = async (req, res) => {
try {
const userId = isLogin(req) ? getLoginInfo(req).oid : null;
const user = userId && (await userModel.findOne({ _id: userId }).lean());
const user =
userId &&
(await userModel.findOne({ _id: userId, withdraw: false }).lean());

const eventStatus =
userId &&
Expand Down Expand Up @@ -99,7 +101,7 @@ const createUserGlobalStateHandler = async (req, res) => {
error: "GlobalState/Create : inviter did not participate in the event",
});

const user = await userModel.findOne({ _id: req.userOid });
const user = await userModel.findOne({ _id: req.userOid, withdraw: false });
if (!user)
return res
.status(500)
Expand Down
5 changes: 4 additions & 1 deletion src/lottery/services/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ const searchInviterHandler = async (req, res) => {
)
return res.status(400).json({ error: "Invite/Search : invalid inviter" });

const inviterInfo = await userModel.findOne({ _id: inviterStatus.userId });
const inviterInfo = await userModel.findOne({
_id: inviterStatus.userId,
withdraw: false,
});
if (!inviterInfo)
return res
.status(500)
Expand Down
10 changes: 7 additions & 3 deletions src/lottery/services/publicNotice.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const getRecentPurchaceItemListHandler = async (req, res) => {
.find({ type: "use", itemType: 0 })
.sort({ createAt: -1 })
.limit(1000)
.populate(publicNoticePopulateOption)
.populate(publicNoticePopulateOption) // TODO: 회원 탈퇴 핸들링
.lean()
)
.sort(
Expand Down Expand Up @@ -132,7 +132,9 @@ const getTicketLeaderboardHandler = async (req, res) => {
);
const leaderboard = await Promise.all(
sortedUsers.slice(0, 20).map(async (user) => {
const userInfo = await userModel.findOne({ _id: user.userId }).lean();
const userInfo = await userModel
.findOne({ _id: user.userId, withdraw: false })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시 나중에 코드볼때 헷갈릴 것 같아서
// user.userId는 userOid입니다.
이런 주석 하나만 달아주시면 좋을듯 합니다

.lean();
if (!userInfo) {
logger.error(`Fail to find user ${user.userId}`);
return null;
Expand Down Expand Up @@ -211,7 +213,9 @@ const getGroupLeaderboardHandler = async (req, res) => {
if (mvp?.length !== 1)
throw new Error(`Fail to find MVP in group ${group.group}`);

const mvpInfo = await userModel.findOne({ _id: mvp[0].userId }).lean();
const mvpInfo = await userModel
.findOne({ _id: mvp[0].userId, withdraw: false })
.lean();
if (!mvpInfo) throw new Error(`Fail to find user ${mvp[0].userId}`);

return {
Expand Down
3 changes: 1 addition & 2 deletions src/middlewares/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ const authMiddleware = (req, res, next) => {
error: "not logged in",
});
} else {
const { id, oid } = getLoginInfo(req);
req.userId = id;
const { oid } = getLoginInfo(req);
req.userOid = oid;
next();
}
Expand Down
4 changes: 2 additions & 2 deletions src/middlewares/authAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const authAdminMiddleware = async (req, res, next) => {
if (!isLogin(req)) return res.redirect(req.origin);

// 관리자 유무를 확인
const { id } = getLoginInfo(req);
const user = await userModel.findOne({ id });
const { oid } = getLoginInfo(req);
const user = await userModel.findOne({ _id: oid, withdraw: false });
if (!user.isAdmin) return res.redirect(req.origin);

// 접속한 IP가 화이트리스트에 있는지 확인
Expand Down
23 changes: 23 additions & 0 deletions src/modules/fcm.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,28 @@ const unregisterDeviceToken = async (deviceToken) => {
}
};

/**
* 사용자의 ObjectId가 주어졌을 때, 해당 사용자의 모든 deviceToken을 DB에서 삭제합니다.
* @param {string} userId - 사용자의 ObjectId입니다.
* @return {Promise<boolean>} 해당 사용자로부터 deviceToken을 삭제하는 데 성공하면 true, 실패하면 false를 반환합니다. 삭제할 deviceToken이 존재하지 않는 경우에는 true를 반환합니다.
*/
const unregisterAllDeviceTokens = async (userId) => {
try {
// 사용자의 디바이스 토큰을 DB에서 가져옵니다.
// getTokensOfUsers 함수의 정의는 아래에 있습니다. (호이스팅)
const tokens = await getTokensOfUsers([userId]);

// 디바이스 토큰과 관련 설정을 DB에서 삭제합니다.
await deviceTokenModel.deleteMany({ userId });
await notificationOptionModel.deleteMany({ deviceToken: { $in: tokens } });

return true;
} catch (error) {
logger.error(error);
return false;
}
};

/**
* 메시지 전송에 실패한 deviceToken을 DB에서 삭제합니다.
* @param {Array<string>} deviceTokens - 사용자의 ObjectId입니다.
Expand Down Expand Up @@ -351,6 +373,7 @@ module.exports = {
initializeApp,
registerDeviceToken,
unregisterDeviceToken,
unregisterAllDeviceTokens,
validateDeviceToken,
getTokensOfUsers,
sendMessageByTokens,
Expand Down
6 changes: 5 additions & 1 deletion src/modules/populates/chats.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
* 쿼리를 통해 얻은 Chat Document를 populate할 설정값을 정의합니다.
*/
const chatPopulateOption = [
{ path: "authorId", select: "_id nickname profileImageUrl" },
{
path: "authorId",
select: "_id nickname profileImageUrl",
match: { withdraw: false },
},
];

module.exports = {
Expand Down
1 change: 1 addition & 0 deletions src/modules/populates/reports.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const reportPopulateOption = [
{
path: "reportedId",
select: "_id id name nickname profileImageUrl",
match: { withdraw: false },
},
];

Expand Down
8 changes: 7 additions & 1 deletion src/modules/populates/rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ const roomPopulateOption = [
{
path: "part",
select: "-_id user settlementStatus readAt",
populate: { path: "user", select: "_id id name nickname profileImageUrl" },
populate: {
path: "user",
select: "_id id name nickname profileImageUrl",
match: { withdraw: false },
},
},
];

Expand All @@ -27,6 +31,8 @@ const formatSettlement = (
{ includeSettlement = true, isOver = false, timestamp = Date.now() } = {}
) => {
roomObject.part = roomObject.part.map((participantSubDocument) => {
if (!participantSubDocument.user) return null;

const { _id, name, nickname, profileImageUrl } =
participantSubDocument.user;
const { settlementStatus, readAt } = participantSubDocument;
Expand Down
21 changes: 12 additions & 9 deletions src/modules/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,20 @@ const transformChatsForRoom = async (chats) => {
const inOutUserIds = chat.content.split("|");
chat.inOutNames = await Promise.all(
inOutUserIds.map(async (userId) => {
const user = await userModel.findOne({ id: userId }, "nickname");
return user.nickname;
const user = await userModel.findOne(
{ id: userId, withdraw: false }, // NOTE: SSO uid 쓰는 곳
"nickname"
);
return user?.nickname;
kmc7468 marked this conversation as resolved.
Show resolved Hide resolved
})
);
}
chatsToSend.push({
roomId: chat.roomId,
type: chat.type,
authorId: chat.authorId?._id,
authorName: chat.authorId?.nickname,
authorProfileUrl: chat.authorId?.profileImageUrl,
authorId: chat.authorId?._id ?? null,
authorName: chat.authorId?.nickname ?? null,
authorProfileUrl: chat.authorId?.profileImageUrl ?? null,
kmc7468 marked this conversation as resolved.
Show resolved Hide resolved
content: chat.content,
time: chat.time,
isValid: chat.isValid,
Expand Down Expand Up @@ -136,7 +139,10 @@ const emitChatEvent = async (io, chat) => {

// chat optionally contains authorId
const { nickname, profileImageUrl } = authorId
? await userModel.findById(authorId, "nickname profileImageUrl")
? await userModel.findOne(
{ _id: authorId, withdraw: false },
"nickname profileImageUrl"
)
: {};
if (authorId && (!nickname || !profileImageUrl)) {
throw new IllegalArgumentsException();
Expand All @@ -163,9 +169,6 @@ const emitChatEvent = async (io, chat) => {
.lean()
.populate(chatPopulateOption);

chatDocument.authorName = nickname;
chatDocument.authorProfileUrl = profileImageUrl;

const userIds = part.map((participant) => participant.user);
const userIdsExceptAuthor = authorId
? part
Expand Down
5 changes: 3 additions & 2 deletions src/modules/stores/mongo.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ const logger = require("../logger");
const userSchema = Schema({
name: { type: String, required: true }, //실명
nickname: { type: String, required: true }, //닉네임
id: { type: String, required: true, unique: true }, //택시 서비스에서만 사용되는 id
id: { type: String, required: true }, //택시 서비스에서만 사용되는 id
profileImageUrl: { type: String, required: true }, //백엔드에서의 프로필 이미지 경로
ongoingRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 진행중인 방 배열
doneRoom: [{ type: Schema.Types.ObjectId, ref: "Room" }], // 참여중인 완료된 방 배열
withdraw: { type: Boolean, default: false },
withdraw: { type: Boolean, default: false }, //탈퇴 여부
withdrawAt: { type: Date }, //탈퇴 시각
phoneNumber: { type: String }, // 전화번호 (2023FALL 이벤트부터 추가)
ban: { type: Boolean, default: false }, //계정 정지 여부
joinat: { type: Date, required: true }, //가입 시각
Expand Down
31 changes: 31 additions & 0 deletions src/routes/docs/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,35 @@ usersDocs[`${apiPrefix}/getBanRecord`] = {
},
};

usersDocs[`${apiPrefix}/withdraw`] = {
post: {
tags: [tag],
summary: "회원 탈퇴",
description: "회원 탈퇴를 요청합니다.",
responses: {
200: {
content: {
"text/html": {
example: "Users/withdraw : withdraw successful",
},
},
},
400: {
content: {
"text/html": {
example: "Users/withdraw : ongoing room exists",
},
},
},
500: {
content: {
"text/html": {
example: "Users/withdraw : internal server error",
},
},
},
},
},
};

module.exports = usersDocs;
3 changes: 3 additions & 0 deletions src/routes/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,7 @@ router.get("/isBanned", userHandlers.isBannedHandler);
// 유저의 서비스 정지 기록들을 모두 반환합니다.
router.get("/getBanRecord", userHandlers.getBanRecordHandler);

// 회원 탈퇴를 요청합니다.
router.post("/withdraw", userHandlers.withdrawHandler);

module.exports = router;
40 changes: 40 additions & 0 deletions src/schedules/deleteUserInfo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { userModel } = require("../modules/stores/mongo");
const logger = require("../modules/logger");

module.exports = async () => {
try {
// 탈퇴일로부터 1년 이상 경과한 사용자의 개인정보 삭제
await userModel.updateMany(
{
withdraw: true,
withdrawAt: { $lte: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
name: { $ne: "" },
},
{
$set: {
name: "",
nickname: "",
id: "",
profileImageUrl: "",
// ongoingRoom
// doneRoom
ban: false,
// joinat
agreeOnTermsOfService: false,
"subinfo.kaist": "",
"subinfo.sparcs": "",
"subinfo.facebook": "",
"subinfo.twitter": "",
email: "",
isAdmin: false,
account: "",
},
$unset: {
phoneNumber: "",
},
}
);
} catch (err) {
logger.error(err);
}
};
1 change: 1 addition & 0 deletions src/schedules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const cron = require("node-cron");
const registerSchedules = (app) => {
cron.schedule("*/5 * * * *", require("./notifyBeforeDepart")(app));
cron.schedule("*/10 * * * *", require("./notifyAfterArrival")(app));
cron.schedule("0 0 1 * *", require("./deleteUserInfo"));
};

module.exports = registerSchedules;
1 change: 0 additions & 1 deletion src/schedules/notifyAfterArrival.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const { roomModel, chatModel } = require("../modules/stores/mongo");
// const { roomPopulateOption } = require("../modules/populates/rooms");
const { emitChatEvent } = require("../modules/socket");
const logger = require("../modules/logger");

Expand Down
4 changes: 2 additions & 2 deletions src/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const update = async (userData) => {
email: userData.email,
"subinfo.kaist": userData.kaist,
};
await userModel.updateOne({ id: userData.id }, updateInfo);
await userModel.updateOne({ id: userData.id, withdraw: false }, updateInfo); // NOTE: SSO uid 쓰는 곳
logger.info(
`Update user info: ${userData.id} ${userData.name} ${userData.email} ${userData.kaist}`
);
Expand All @@ -72,7 +72,7 @@ const update = async (userData) => {
const tryLogin = async (req, res, userData, redirectOrigin, redirectPath) => {
try {
const user = await userModel.findOne(
{ id: userData.id },
{ id: userData.id, withdraw: false }, // NOTE: SSO uid 쓰는 곳
"_id name email subinfo id withdraw ban"
);
if (!user) {
Expand Down
2 changes: 1 addition & 1 deletion src/services/auth.mobile.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const tokenLoginHandler = async (req, res) => {
return res.status(401).json({ message: "Not Access token" });
}

const user = await userModel.findOne({ _id: data.id });
const user = await userModel.findOne({ _id: data.id, withdraw: false });
if (!user) {
return res.status(401).json({ message: "No corresponding user" });
}
Expand Down
Loading