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 18 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/node_modules
/dist
.tsbuildinfo
/dump
.env
.env.test
Expand Down
2 changes: 1 addition & 1 deletion src/lottery/services/globalState.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const createUserGlobalStateHandler = async (req, res) => {
error: "GlobalState/create : invalid inviter",
});

const user = await userModel.findById(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/invites.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ const searchInviterHandler = async (req, res) => {

// 해당되는 유저의 닉네임과 프로필 이미지를 가져옵니다.
const inviter = await userModel
.findById(inviterStatus.userId, "nickname profileImageUrl")
.findOne(
{ _id: inviterStatus.userId, withdraw: false },
"nickname profileImageUrl"
)
.lean();
if (!inviter)
return res
Expand Down
12 changes: 11 additions & 1 deletion src/lottery/services/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,13 @@ const getItemLeaderboardHandler = async (req, res) => {
leaderboardBase
.filter((user) => user.rank <= 20)
.map(async (user) => {
const userInfo = await userModel.findById(user.userId).lean();
const userInfo = await userModel
.findOne({ _id: user.userId, withdraw: false })
.lean();
if (!userInfo) {
logger.error(`Fail to find user ${user.userId}`);
return null;
}
return {
nickname: userInfo.nickname,
profileImageUrl: userInfo.profileImageUrl,
Expand All @@ -135,6 +141,10 @@ const getItemLeaderboardHandler = async (req, res) => {
};
})
);
if (leaderboard.includes(null))
return res
.status(500)
.json({ error: "Items/leaderboard : internal server error" });

const userId = isLogin(req) ? getLoginInfo(req).oid : null;
const user = leaderboardBase.find(
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
4 changes: 2 additions & 2 deletions src/middlewares/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ const authMiddleware: RequestHandler = (req, res, next) => {
error: "not logged in",
});

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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const authAdminMiddleware: RequestHandler = async (req, res, next) => {
if (!isLogin(req)) return res.redirect(redirectUrl);

// 관리자 유무를 확인
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(redirectUrl);

// 접속한 IP가 화이트리스트에 있는지 확인
Expand Down
2 changes: 1 addition & 1 deletion src/modules/ban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const validateServiceBanRecord = async (
.toISOString()
.replace("T", " ")
.split(".")[0];
const banErrorMessage = `${req.originalUrl} : user ${req.userId} (${
const banErrorMessage = `${req.originalUrl} : user ${req.userOid} (${
req.session.loginInfo!.sid
}) is temporarily restricted from service until ${formattedExpireAt}.`;
return banErrorMessage;
Expand Down
22 changes: 22 additions & 0 deletions src/modules/fcm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ export const unregisterDeviceToken = async (deviceToken: string) => {
}
};

/**
* 사용자의 ObjectId가 주어졌을 때, 해당 사용자의 모든 deviceToken을 DB에서 삭제합니다.
* @param userId - 사용자의 ObjectId입니다.
* @return 해당 사용자로부터 deviceToken을 삭제하는 데 성공하면 true, 실패하면 false를 반환합니다. 삭제할 deviceToken이 존재하지 않는 경우에는 true를 반환합니다.
*/
export const unregisterAllDeviceTokens = async (userId: string) => {
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 deviceTokens - 사용자의 ObjectId입니다.
Expand Down
10 changes: 8 additions & 2 deletions src/modules/populates/chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import type { User, Chat } from "@/types/mongo";
* 쿼리를 통해 얻은 Chat Document를 populate할 설정값을 정의합니다.
*/
export const chatPopulateOption = [
{ path: "authorId", select: "_id nickname profileImageUrl" },
{
path: "authorId",
select: "_id nickname profileImageUrl withdraw",
},
];

export interface PopulatedChat extends Omit<Chat, "authorId"> {
authorId?: Pick<User, "_id" | "nickname" | "profileImageUrl">;
authorId: Pick<
User,
"_id" | "nickname" | "profileImageUrl" | "withdraw"
> | null;
}
6 changes: 3 additions & 3 deletions src/modules/populates/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import type { User, Report } from "@/types/mongo";
export const reportPopulateOption = [
{
path: "reportedId",
select: "_id id name nickname profileImageUrl",
select: "_id id name nickname profileImageUrl withdraw",
},
];

export interface PopulatedReport extends Omit<Report, "reportedId"> {
reportedId: Pick<
User,
"_id" | "id" | "name" | "nickname" | "profileImageUrl"
>;
"_id" | "id" | "name" | "nickname" | "profileImageUrl" | "withdraw"
> | null;
}
57 changes: 44 additions & 13 deletions src/modules/populates/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,47 @@ export 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 withdraw",
},
},
];

interface PopulatedParticipant
extends Pick<Participant, "settlementStatus" | "readAt"> {
user: Pick<User, "_id" | "id" | "name" | "nickname" | "profileImageUrl">;
user: Pick<
User,
"_id" | "id" | "name" | "nickname" | "profileImageUrl" | "withdraw"
> | null;
}

export interface PopulatedRoom extends Omit<Room, "from" | "to" | "part"> {
from: Pick<Location, "_id" | "koName" | "enName">;
to: Pick<Location, "_id" | "koName" | "enName">;
part?: PopulatedParticipant[];
from: Pick<Location, "_id" | "koName" | "enName"> | null;
to: Pick<Location, "_id" | "koName" | "enName"> | null;
part: PopulatedParticipant[];
}

export interface FormattedRoom
extends Omit<PopulatedRoom, "part" | "settlementTotal"> {
part?: {
interface FormattedLocation {
_id: string;
enName: string;
koName: string;
}

export interface FormattedRoom {
_id: string;
name: string;
from: FormattedLocation;
to: FormattedLocation;
time: Date;
madeat: Date;
maxPartLength: number;
part: {
_id: string;
name: string;
nickname: string;
profileImageUrl: string;
withdraw: boolean;
isSettlement?: SettlementStatus;
readAt: Date;
}[];
Expand All @@ -61,15 +80,27 @@ export const formatSettlement = (
): FormattedRoom => {
return {
...roomObject,
part: roomObject.part?.map((participantSubDocument) => {
const { _id, name, nickname, profileImageUrl } =
participantSubDocument.user;
_id: roomObject._id!.toString(),
from: {
_id: roomObject.from!._id!.toString(),
enName: roomObject.from!.enName,
koName: roomObject.from!.koName,
},
to: {
_id: roomObject.to!._id!.toString(),
enName: roomObject.to!.enName,
koName: roomObject.to!.koName,
},
part: roomObject.part.map((participantSubDocument) => {
const { _id, name, nickname, profileImageUrl, withdraw } =
participantSubDocument.user!;
const { settlementStatus, readAt } = participantSubDocument;
return {
_id,
_id: _id!.toString(),
name,
nickname,
profileImageUrl,
withdraw,
isSettlement: includeSettlement ? settlementStatus : undefined,
readAt: readAt ?? roomObject.madeat,
};
Expand All @@ -89,7 +120,7 @@ export const formatSettlement = (
export const getIsOver = (roomObject: PopulatedRoom, userId: string) => {
// room document의 part subdoocument에서 사용자 id와 일치하는 정산 정보를 찾습니다.
const participantSubDocuments = roomObject.part?.filter((part) => {
return part.user.id === userId;
return part.user?.id === userId;
});

// 방에 참여중이지 않은 사용자의 경우, undefined을 반환합니다.
Expand Down
16 changes: 10 additions & 6 deletions src/modules/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ 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
})
);
}
Expand All @@ -46,6 +49,7 @@ const transformChatsForRoom = async (chats) => {
authorId: chat.authorId?._id,
authorName: chat.authorId?.nickname,
authorProfileUrl: chat.authorId?.profileImageUrl,
authorIsWithdrew: chat.authorId?.withdraw,
content: chat.content,
time: chat.time,
isValid: chat.isValid,
Expand Down Expand Up @@ -136,7 +140,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 +170,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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import type {
const userSchema = new Schema<User>({
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 @@ -393,4 +393,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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,7 @@ router.get("/resetProfileImg", userHandlers.resetProfileImgHandler);
// 유저의 서비스 정지 기록들을 모두 반환합니다.
router.get("/getBanRecord", userHandlers.getBanRecordHandler);

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

export default router;
Loading
Loading