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 all 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
41 changes: 41 additions & 0 deletions scripts/chatContentUserIdUpdater.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { MongoClient } = require("mongodb");
const { mongo: mongoUrl } = require("@/loadenv");

const client = new MongoClient(mongoUrl);
const db = client.db("taxi");
const chats = db.collection("chats");
const users = db.collection("users");

async function convertUserIdToOid(userId) {
const user = await users.findOne({ id: userId, withdraw: false }, "_id");
if (!user) throw new Error(`User not found: ${userId}`);
return user._id.toString();
}

async function run() {
try {
for await (const doc of chats.find()) {
if (doc.type === "in" || doc.type === "out") {
const inOutUserIds = doc.content.split("|");
const inOutUserOids = await Promise.all(
inOutUserIds.map(convertUserIdToOid)
);
await chats.updateOne(
{ _id: doc._id },
{ $set: { content: inOutUserOids.join("|") } }
);
} else if (doc.type === "payment" || doc.type === "settlement") {
const userId = doc.content;
const userOid = await convertUserIdToOid(userId);
await chats.updateOne({ _id: doc._id }, { $set: { content: userOid } });
}
}
} catch (err) {
console.error(err);
} finally {
await client.close();
}
}
run().then(() => {
console.log("Done!");
});
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
9 changes: 8 additions & 1 deletion src/modules/auths/login.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { Request } from "express";
import { session as sessionConfig } from "@/loadenv";
import { session as sessionConfig, sparcssso as sparcsssoEnv } from "@/loadenv";
import logger from "@/modules/logger";
import SsoClient from "./sparcssso";

// 환경변수 SPARCSSSO_CLIENT_ID 유무에 따라 로그인 방식이 변경됩니다.
export const isAuthReplace = !sparcsssoEnv.id;
export const ssoClient = !isAuthReplace
? new SsoClient(sparcsssoEnv.id, sparcsssoEnv.key)
: undefined;

export interface LoginInfo {
id: string;
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;
}
63 changes: 47 additions & 16 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 @@ -81,15 +112,15 @@ export const formatSettlement = (
};

/**
* roomPopulateOption을 사용해 populate된 Room Object와 사용자의 id(userId)가 주어졌을 때, 해당 사용자의 정산 상태를 반환합니다.
* roomPopulateOption을 사용해 populate된 Room Object와 사용자의 objectId가 주어졌을 때, 해당 사용자의 정산 상태를 반환합니다.
* @param roomObject - roomPopulateOption을 사용해 populate된 변환한 Room Object입니다.
* @param userId - 방 완료 상태를 확인하려는 사용자의 id(user.id)입니다.
* @param userOid - 방 완료 상태를 확인하려는 사용자의 objectId입니다.
* @return 사용자의 해당 방에 대한 완료 여부(true | false)를 반환합니다. 사용자가 참여중인 방이 아닐 경우 undefined를 반환합니다.
**/
export const getIsOver = (roomObject: PopulatedRoom, userId: string) => {
export const getIsOver = (roomObject: PopulatedRoom, userOid: string) => {
// room document의 part subdoocument에서 사용자 id와 일치하는 정산 정보를 찾습니다.
const participantSubDocuments = roomObject.part?.filter((part) => {
return part.user.id === userId;
return part.user?._id?.toString() === userOid;
});

// 방에 참여중이지 않은 사용자의 경우, undefined을 반환합니다.
Expand Down
15 changes: 8 additions & 7 deletions src/modules/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ 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.findById(userId, "nickname");
return user?.nickname;
})
);
}
Expand All @@ -46,6 +46,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 @@ -111,7 +112,7 @@ const getMessageBody = (type, nickname = "", content = "") => {
* @param {Object} chat - 채팅 메시지 내용입니다.
* @param {string} chat.roomId - 채팅 및 채팅 알림을 보낼 방의 ObjectId입니다.
* @param {string} chat.type - 채팅 메시지의 유형입니다. "text" | "s3img" | "in" | "out" | "payment" | "settlement" | "account" | "departure" | "arrival" 입니다.
* @param {string} chat.content - 채팅 메시지의 본문입니다. chat.type이 "s3img"인 경우에는 채팅의 objectId입니다. chat.type이 "in"이거나 "out"인 경우 입퇴장한 사용자의 id(!==ObjectId)입니다.
* @param {string} chat.content - 채팅 메시지의 본문입니다. chat.type이 "s3img"인 경우에는 채팅의 objectId입니다. chat.type이 "in"이거나 "out"인 경우 입퇴장한 사용자의 oid입니다.
* @param {string} chat.authorId - optional. 채팅을 보낸 사용자의 ObjectId입니다.
* @param {Date?} chat.time - optional. 채팅 메시지 전송 시각입니다.
* @return {Promise<Boolean>} 채팅 및 알림 전송에 성공하면 true, 중간에 오류가 발생하면 false를 반환합니다.
Expand All @@ -136,7 +137,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 +167,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
Loading
Loading