Skip to content

Commit 6cbd8f5

Browse files
authored
Merge pull request #70 from Chaellimi/feat/#69
[Feat/#69] 챌린지 관련 API 연동
2 parents 413bfed + 0affc32 commit 6cbd8f5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1019
-267
lines changed

next.config.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ const nextConfig: NextConfig = {
1414
},
1515
serverExternalPackages: ['sequelize', 'mysql2'],
1616
images: {
17-
domains: ['www.google.com', 'img.freepik.com', 'i.pinimg.com'],
17+
domains: [
18+
'www.google.com',
19+
'img.freepik.com',
20+
'i.pinimg.com',
21+
'lh3.googleusercontent.com',
22+
'chaellimi.jamkris.kro.kr',
23+
'localhost',
24+
],
1825
},
1926
reactStrictMode: true,
2027
output: 'standalone',

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@
2626
]
2727
},
2828
"dependencies": {
29+
"@tanstack/react-query": "^5.80.7",
30+
"@tanstack/react-query-devtools": "^5.80.7",
31+
"axios": "^1.10.0",
2932
"classnames": "^2.5.1",
3033
"dotenv": "^16.5.0",
3134
"framer-motion": "^12.7.4",
35+
"html2canvas": "^1.4.1",
3236
"mysql2": "^3.14.0",
3337
"next": "15.3.0",
3438
"next-auth": "^4.24.11",

public/icons/shared/SpinLogo.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
3+
interface SpinLogoProps {
4+
animationDuration?: string;
5+
width?: number;
6+
height?: number;
7+
}
8+
9+
const SpinLogo = ({ animationDuration, width, height }: SpinLogoProps) => {
10+
return (
11+
<div
12+
className="animate-spin"
13+
style={{ animationDuration: animationDuration || '2s' }}
14+
>
15+
<svg
16+
width={width || '105'}
17+
height={height || '94'}
18+
viewBox="0 0 105 94"
19+
fill="none"
20+
xmlns="http://www.w3.org/2000/svg"
21+
>
22+
<path
23+
fillRule="evenodd"
24+
clipRule="evenodd"
25+
d="M69.1718 50.4851L69.4754 50.475C69.6823 50.475 69.7 50.7191 69.4946 50.7441C56.075 52.3784 48.8381 36.0257 60.146 26.9927L74.3144 18.784C75.6349 18.019 76.1527 16.7738 75.4893 15.3994C74.6761 13.7149 73.4803 11.4404 72.687 9.93165C72.3059 9.20674 72.0177 8.65861 71.9095 8.4376C65.0088 -5.6646 44.7511 -1.3039 46.1153 15.6784C46.1228 15.7718 46.01 15.8309 45.9438 15.7647L44.0185 13.8394C33.99 1.6997 14.5064 12.1474 21.1162 29.4992C21.1489 29.5851 21.0578 29.6718 20.9753 29.6312C9.39574 23.9326 -1.58366 30.665 0.189138 44.4414C0.694538 48.3845 2.03424 52.5609 3.71164 56.2677C4.40056 57.4625 5.07138 58.6463 5.73514 59.8177C10.6924 68.5661 15.2562 76.6201 24.0278 83.357C61.3611 112.033 115.845 78.197 102.888 32.9321C99.135 19.8457 92.811 14.0568 78.028 18.58C73.8019 20.8471 66.4087 24.8351 62.5808 27.6286C54.239 33.7171 55.1658 44.8754 63.329 49.3983C65.0924 50.3753 67.157 50.5526 69.1718 50.4851Z"
26+
fill="#FF6A00"
27+
/>
28+
</svg>
29+
</div>
30+
);
31+
};
32+
33+
export default SpinLogo;

public/icons/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { default as ShareIcon } from './Share';
66
export { default as EditIcon } from './Edit';
77
export { default as TrashIcon } from './Trash';
88
export { default as BookmarkIcon } from './Bookmark';
9+
export { default as SpinLogo } from './SpinLogo';

src/app/api/apitest/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ async function handler() {
1212
data: {},
1313
})
1414
);
15-
} catch (error) {
16-
logger.error('API Test handler error:', { error });
17-
return NextResponse.json(resUtil.unknownError);
15+
} catch (err) {
16+
logger.error('API Test handler error:', { err });
17+
return resUtil.unknownError({ data: { err } });
1818
}
1919
}
2020

src/app/api/challenge/Challenge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ export interface ChallengeData {
33
description: string;
44
category: string;
55
difficulty: 'easy' | 'normal' | 'hard';
6-
day: string;
6+
day: number;
77
imgURL: string;
88
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { withAuth } from '@/lib/middleware/withAuth';
2+
import { withLogging } from '@/lib/middleware/withLogging';
3+
import { NextRequest } from 'next/server';
4+
import Challenge from '@/database/models/Challenge';
5+
import ChallengeParticipants from '@/database/models/ChallengeParticipants';
6+
import resUtil from '@/lib/utils/responseUtil';
7+
import getUserFromRequest from '@/lib/utils/getUserFromRequest';
8+
9+
async function joinHandler(req: NextRequest) {
10+
try {
11+
const challengeId = Number(req.nextUrl.pathname.split('/')[3]);
12+
if (!challengeId) {
13+
return resUtil.successFalse({
14+
status: 400,
15+
message: '챌린지 ID가 제공되지 않았습니다',
16+
});
17+
}
18+
19+
const challenge = await Challenge.findByPk(challengeId);
20+
if (!challenge) {
21+
return resUtil.successFalse({
22+
status: 404,
23+
message: '해당 챌린지를 찾을 수 없습니다',
24+
});
25+
}
26+
27+
const user = await getUserFromRequest();
28+
29+
if (!user) {
30+
return resUtil.unauthorized({});
31+
}
32+
33+
const alreadyJoined = await ChallengeParticipants.findOne({
34+
where: { challengeId, userId: user.id },
35+
});
36+
37+
if (alreadyJoined) {
38+
return resUtil.successFalse({
39+
status: 409,
40+
message: '이미 참여 중인 챌린지입니다',
41+
});
42+
}
43+
44+
await ChallengeParticipants.create({
45+
userId: user.id,
46+
challengeId,
47+
joinedAt: new Date().toISOString(),
48+
streak: '0',
49+
status: 'active',
50+
});
51+
52+
return resUtil.successTrue({
53+
status: 201,
54+
message: '챌린지 참여 완료',
55+
});
56+
} catch (err) {
57+
return resUtil.unknownError({ data: { err } });
58+
}
59+
}
60+
61+
const JoinChallenge = withLogging(withAuth(joinHandler));
62+
63+
export const POST = JoinChallenge;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { withAuth } from '@/lib/middleware/withAuth';
2+
import { withLogging } from '@/lib/middleware/withLogging';
3+
import { NextRequest } from 'next/server';
4+
import { Op } from 'sequelize';
5+
import Challenge from '@/database/models/Challenge';
6+
import Users from '@/database/models/User';
7+
import ChallengeParticipants from '@/database/models/ChallengeParticipants';
8+
import resUtil from '@/lib/utils/responseUtil';
9+
import getUserFromRequest from '@/lib/utils/getUserFromRequest';
10+
11+
async function getHandler(req: NextRequest) {
12+
try {
13+
const challengeId = Number(req.nextUrl.pathname.split('/')[3]);
14+
if (!challengeId) {
15+
return resUtil.successFalse({
16+
status: 400,
17+
message: '챌린지 ID가 제공되지 않았습니다',
18+
});
19+
}
20+
21+
const challenge = await Challenge.findOne({
22+
where: { id: challengeId },
23+
include: [
24+
{
25+
model: Users,
26+
as: 'User',
27+
attributes: ['name', 'profileImg'],
28+
},
29+
],
30+
});
31+
32+
if (!challenge) {
33+
return resUtil.successFalse({
34+
status: 404,
35+
message: '해당 챌린지를 찾을 수 없습니다',
36+
});
37+
}
38+
39+
const userId = challenge.userId;
40+
41+
const totalChallenges = await Challenge.count({
42+
where: { userId },
43+
});
44+
45+
const recentChallenges = await Challenge.findAll({
46+
where: {
47+
userId,
48+
id: { [Op.ne]: challengeId },
49+
},
50+
order: [['createdAt', 'DESC']],
51+
limit: 6,
52+
});
53+
54+
const loginUser = await getUserFromRequest();
55+
56+
let joinStatus: 'not_joined' | 'in_progress' | 'completed' | 'failed' =
57+
'not_joined';
58+
59+
if (loginUser) {
60+
const participant = await ChallengeParticipants.findOne({
61+
where: {
62+
challengeId,
63+
userId: loginUser.id,
64+
},
65+
});
66+
67+
if (participant) {
68+
if (participant.status === 'completed') {
69+
joinStatus = 'completed';
70+
} else if (participant.status === 'failed') {
71+
joinStatus = 'failed';
72+
} else {
73+
joinStatus = 'in_progress';
74+
}
75+
}
76+
}
77+
78+
return resUtil.successTrue({
79+
status: 200,
80+
message: '챌린지 조회 성공',
81+
data: {
82+
challenge,
83+
totalChallenges,
84+
recentChallenges,
85+
joinStatus,
86+
},
87+
});
88+
} catch (err) {
89+
return resUtil.unknownError({ data: { err } });
90+
}
91+
}
92+
93+
const GetChallengeById = withLogging(withAuth(getHandler));
94+
export const GET = GetChallengeById;

src/app/api/challenge/controllers/createChallenge.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ async function postHandler(req: NextRequest) {
5050
message: '챌린지 생성 성공',
5151
data: newChallenge,
5252
});
53-
} catch (error) {
54-
console.error(error);
55-
return resUtil.unknownError({});
53+
} catch (err) {
54+
return resUtil.unknownError({ data: { err } });
5655
}
5756
}
5857

src/app/api/challenge/controllers/getChallenge.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NextRequest } from 'next/server';
44
import Challenge from '@/database/models/Challenge';
55
import resUtil from '@/lib/utils/responseUtil';
66
import { Op } from 'sequelize';
7+
import Users from '@/database/models/User';
78

89
async function getHandler(req: NextRequest) {
910
try {
@@ -68,7 +69,16 @@ async function getHandler(req: NextRequest) {
6869
queryOptions.offset = offset;
6970
}
7071

71-
const challenges = await Challenge.findAll(queryOptions);
72+
const challenges = await Challenge.findAll({
73+
...queryOptions,
74+
include: [
75+
{
76+
model: Users,
77+
as: 'User',
78+
attributes: ['name', 'profileImg'],
79+
},
80+
],
81+
});
7282

7383
return resUtil.successTrue({
7484
status: 200,
@@ -81,8 +91,8 @@ async function getHandler(req: NextRequest) {
8191
: null,
8292
},
8393
});
84-
} catch {
85-
return resUtil.unknownError({});
94+
} catch (err) {
95+
return resUtil.unknownError({ data: { err } });
8696
}
8797
}
8898

0 commit comments

Comments
 (0)