bluewhale2025 commited on
Commit
3d19d88
·
1 Parent(s): ffc6bd8

feat: 인센티브 시스템 구현 및 타입 정의 추가

Browse files
src/graphql/resolvers.ts CHANGED
@@ -15,27 +15,21 @@ interface PostInput {
15
  }
16
 
17
  type PubSubEvents = {
18
- [K in 'POST_CREATED' | 'POST_UPDATED' | 'POST_DELETED' | 'COMMIT_CREATED' | '_PING']: K extends '_PING'
19
- ? boolean
20
- : K extends 'POST_DELETED'
21
- ? string
22
- : K extends 'COMMIT_CREATED'
23
- ? any
24
- : Post;
25
- };
26
-
27
- interface PubSubEvents {
28
  USER_POINTS_UPDATED: { userPointsUpdated: User };
29
  NEW_NFT_MINTED: { newNFTMinted: NFT };
30
  CONTRIBUTION_STATUS_CHANGED: { contributionStatusChanged: Contribution };
31
  LEADERBOARD_UPDATED: { leaderboardUpdated: LeaderboardEntry[] };
32
- }
33
 
34
- const pubsub = new PubSub<PubSubEvents>();
35
 
36
  const startPingInterval = () => {
37
  setInterval(() => {
38
- pubsub.publish('_PING', true);
39
  }, 5000);
40
  };
41
 
@@ -138,7 +132,7 @@ export const resolvers = {
138
  createPost: async (_: unknown, { input }: { input: PostInput }) => {
139
  try {
140
  const post = await createPost(input);
141
- await pubsub.publish('POST_CREATED', post);
142
  return post;
143
  } catch (error) {
144
  console.error('Error creating post:', error);
@@ -148,7 +142,9 @@ export const resolvers = {
148
  updatePost: async (_: unknown, { id, input }: { id: string; input: PostInput }) => {
149
  try {
150
  const post = await updatePost(id, input);
151
- await pubsub.publish('POST_UPDATED', post);
 
 
152
  return post;
153
  } catch (error) {
154
  console.error('Error updating post:', error);
@@ -158,7 +154,9 @@ export const resolvers = {
158
  deletePost: async (_: unknown, { id }: { id: string }) => {
159
  try {
160
  const post = await deletePost(id);
161
- await pubsub.publish('POST_DELETED', post);
 
 
162
  return post;
163
  } catch (error) {
164
  console.error('Error deleting post:', error);
@@ -190,7 +188,7 @@ export const resolvers = {
190
  createdAt: new Date().toISOString(),
191
  };
192
  nfts.push(nft);
193
- await pubsub.publish('NEW_NFT_MINTED', { newNFTMinted: nft });
194
  return nft;
195
  },
196
  createContribution: async (_: any, { input }: { input: { userId: string; type: ContributionType; description: string } }) => {
@@ -222,14 +220,14 @@ export const resolvers = {
222
  if (user) {
223
  user.points += contribution.points;
224
  user.level = calculateLevel(user.points);
225
- await pubsub.publish('USER_POINTS_UPDATED', { userPointsUpdated: user });
226
  }
227
 
228
  // 리더보드 업데이트
229
  const leaderboard = updateLeaderboard();
230
- await pubsub.publish('LEADERBOARD_UPDATED', { leaderboardUpdated: leaderboard });
231
 
232
- await pubsub.publish('CONTRIBUTION_STATUS_CHANGED', { contributionStatusChanged: contribution });
233
  return contribution;
234
  },
235
  rejectContribution: async (_: any, { id }: { id: string }) => {
@@ -239,48 +237,54 @@ export const resolvers = {
239
  throw new Error('Contribution is not pending');
240
 
241
  contribution.status = ContributionStatus.REJECTED;
242
- await pubsub.publish('CONTRIBUTION_STATUS_CHANGED', { contributionStatusChanged: contribution });
243
  return contribution;
244
  },
245
  },
246
 
247
  Subscription: {
248
  postCreated: {
249
- subscribe: () => pubsub.subscribe('POST_CREATED'),
250
  resolve: (payload: { postCreated: Post }) => payload.postCreated
251
  },
252
 
253
  postUpdated: {
254
- subscribe: () => pubsub.subscribe('POST_UPDATED'),
255
  resolve: (payload: { postUpdated: Post }) => payload.postUpdated
256
  },
257
 
258
  postDeleted: {
259
- subscribe: () => pubsub.subscribe('POST_DELETED'),
260
  resolve: (payload: { postDeleted: string }) => payload.postDeleted
261
  },
262
 
263
- commitCreated: {
264
- subscribe: () => pubsub.subscribe('COMMIT_CREATED'),
265
- resolve: (payload: any) => payload
266
- },
267
-
268
- _ping: {
269
  subscribe: () => {
270
  const interval = setInterval(() => {
271
- pubsub.publish('_PING', { _ping: true });
272
  }, 5000);
273
 
274
- return pubsub.subscribe('_PING');
275
- },
276
- resolve: (payload: { _ping: boolean }) => payload._ping
 
 
 
 
 
 
 
 
 
 
 
277
  },
278
  userPointsUpdated: {
279
  subscribe: (_: any, { userId }: { userId: string }) => ({
280
  [Symbol.asyncIterator]: () => ({
281
  next: async () => {
282
  const user = await new Promise<User>(resolve => {
283
- pubsub.subscribe('USER_POINTS_UPDATED', ({ userPointsUpdated }) => {
284
  if (userPointsUpdated.id === userId) {
285
  resolve(userPointsUpdated);
286
  }
@@ -296,7 +300,7 @@ export const resolvers = {
296
  [Symbol.asyncIterator]: () => ({
297
  next: async () => {
298
  const nft = await new Promise<NFT>(resolve => {
299
- pubsub.subscribe('NEW_NFT_MINTED', ({ newNFTMinted }) => {
300
  if (newNFTMinted.owner.id === userId) {
301
  resolve(newNFTMinted);
302
  }
@@ -312,7 +316,7 @@ export const resolvers = {
312
  [Symbol.asyncIterator]: () => ({
313
  next: async () => {
314
  const contribution = await new Promise<Contribution>(resolve => {
315
- pubsub.subscribe('CONTRIBUTION_STATUS_CHANGED', ({ contributionStatusChanged }) => {
316
  if (contributionStatusChanged.user.id === userId) {
317
  resolve(contributionStatusChanged);
318
  }
@@ -324,7 +328,7 @@ export const resolvers = {
324
  }),
325
  },
326
  leaderboardUpdated: {
327
- subscribe: () => pubsub.subscribe('LEADERBOARD_UPDATED'),
328
  },
329
  },
330
  };
 
15
  }
16
 
17
  type PubSubEvents = {
18
+ POST_CREATED: Post;
19
+ POST_UPDATED: Post;
20
+ POST_DELETED: Post;
21
+ PING: { timestamp: number };
 
 
 
 
 
 
22
  USER_POINTS_UPDATED: { userPointsUpdated: User };
23
  NEW_NFT_MINTED: { newNFTMinted: NFT };
24
  CONTRIBUTION_STATUS_CHANGED: { contributionStatusChanged: Contribution };
25
  LEADERBOARD_UPDATED: { leaderboardUpdated: LeaderboardEntry[] };
26
+ };
27
 
28
+ const pubSub = createPubSub<PubSubEvents>();
29
 
30
  const startPingInterval = () => {
31
  setInterval(() => {
32
+ pubSub.publish('PING', { timestamp: Date.now() });
33
  }, 5000);
34
  };
35
 
 
132
  createPost: async (_: unknown, { input }: { input: PostInput }) => {
133
  try {
134
  const post = await createPost(input);
135
+ await pubSub.publish('POST_CREATED', post);
136
  return post;
137
  } catch (error) {
138
  console.error('Error creating post:', error);
 
142
  updatePost: async (_: unknown, { id, input }: { id: string; input: PostInput }) => {
143
  try {
144
  const post = await updatePost(id, input);
145
+ if (post) {
146
+ await pubSub.publish('POST_UPDATED', post);
147
+ }
148
  return post;
149
  } catch (error) {
150
  console.error('Error updating post:', error);
 
154
  deletePost: async (_: unknown, { id }: { id: string }) => {
155
  try {
156
  const post = await deletePost(id);
157
+ if (post) {
158
+ await pubSub.publish('POST_DELETED', post);
159
+ }
160
  return post;
161
  } catch (error) {
162
  console.error('Error deleting post:', error);
 
188
  createdAt: new Date().toISOString(),
189
  };
190
  nfts.push(nft);
191
+ await pubSub.publish('NEW_NFT_MINTED', { newNFTMinted: nft });
192
  return nft;
193
  },
194
  createContribution: async (_: any, { input }: { input: { userId: string; type: ContributionType; description: string } }) => {
 
220
  if (user) {
221
  user.points += contribution.points;
222
  user.level = calculateLevel(user.points);
223
+ await pubSub.publish('USER_POINTS_UPDATED', { userPointsUpdated: user });
224
  }
225
 
226
  // 리더보드 업데이트
227
  const leaderboard = updateLeaderboard();
228
+ await pubSub.publish('LEADERBOARD_UPDATED', { leaderboardUpdated: leaderboard });
229
 
230
+ await pubSub.publish('CONTRIBUTION_STATUS_CHANGED', { contributionStatusChanged: contribution });
231
  return contribution;
232
  },
233
  rejectContribution: async (_: any, { id }: { id: string }) => {
 
237
  throw new Error('Contribution is not pending');
238
 
239
  contribution.status = ContributionStatus.REJECTED;
240
+ await pubSub.publish('CONTRIBUTION_STATUS_CHANGED', { contributionStatusChanged: contribution });
241
  return contribution;
242
  },
243
  },
244
 
245
  Subscription: {
246
  postCreated: {
247
+ subscribe: () => pubSub.subscribe('POST_CREATED'),
248
  resolve: (payload: { postCreated: Post }) => payload.postCreated
249
  },
250
 
251
  postUpdated: {
252
+ subscribe: () => pubSub.subscribe('POST_UPDATED'),
253
  resolve: (payload: { postUpdated: Post }) => payload.postUpdated
254
  },
255
 
256
  postDeleted: {
257
+ subscribe: () => pubSub.subscribe('POST_DELETED'),
258
  resolve: (payload: { postDeleted: string }) => payload.postDeleted
259
  },
260
 
261
+ ping: {
 
 
 
 
 
262
  subscribe: () => {
263
  const interval = setInterval(() => {
264
+ pubSub.publish('PING', { timestamp: Date.now() });
265
  }, 5000);
266
 
267
+ return {
268
+ [Symbol.asyncIterator]() {
269
+ return {
270
+ next() {
271
+ return Promise.resolve({ value: { timestamp: Date.now() }, done: false });
272
+ },
273
+ return() {
274
+ clearInterval(interval);
275
+ return Promise.resolve({ done: true });
276
+ }
277
+ };
278
+ }
279
+ };
280
+ }
281
  },
282
  userPointsUpdated: {
283
  subscribe: (_: any, { userId }: { userId: string }) => ({
284
  [Symbol.asyncIterator]: () => ({
285
  next: async () => {
286
  const user = await new Promise<User>(resolve => {
287
+ pubSub.subscribe('USER_POINTS_UPDATED', ({ userPointsUpdated }) => {
288
  if (userPointsUpdated.id === userId) {
289
  resolve(userPointsUpdated);
290
  }
 
300
  [Symbol.asyncIterator]: () => ({
301
  next: async () => {
302
  const nft = await new Promise<NFT>(resolve => {
303
+ pubSub.subscribe('NEW_NFT_MINTED', ({ newNFTMinted }) => {
304
  if (newNFTMinted.owner.id === userId) {
305
  resolve(newNFTMinted);
306
  }
 
316
  [Symbol.asyncIterator]: () => ({
317
  next: async () => {
318
  const contribution = await new Promise<Contribution>(resolve => {
319
+ pubSub.subscribe('CONTRIBUTION_STATUS_CHANGED', ({ contributionStatusChanged }) => {
320
  if (contributionStatusChanged.user.id === userId) {
321
  resolve(contributionStatusChanged);
322
  }
 
328
  }),
329
  },
330
  leaderboardUpdated: {
331
+ subscribe: () => pubSub.subscribe('LEADERBOARD_UPDATED'),
332
  },
333
  },
334
  };
src/lib/achievement.ts ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { User, Contribution, ContributionType } from '../types/incentive';
3
+ import { createNotification, NotificationType } from './notification';
4
+
5
+ export interface Achievement {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ points: number;
10
+ type: AchievementType;
11
+ requirements: AchievementRequirement[];
12
+ icon: string;
13
+ }
14
+
15
+ export enum AchievementType {
16
+ CONTRIBUTION_COUNT = 'CONTRIBUTION_COUNT',
17
+ POINTS_TOTAL = 'POINTS_TOTAL',
18
+ NFT_COLLECTION = 'NFT_COLLECTION',
19
+ LEVEL_REACHED = 'LEVEL_REACHED',
20
+ SPECIAL = 'SPECIAL',
21
+ }
22
+
23
+ export interface AchievementRequirement {
24
+ type: AchievementType;
25
+ value: number;
26
+ }
27
+
28
+ export async function checkAchievements(userId: string): Promise<Achievement[]> {
29
+ try {
30
+ const user = await prisma.user.findUnique({
31
+ where: { id: userId },
32
+ include: {
33
+ contributions: true,
34
+ nfts: true,
35
+ achievements: true,
36
+ },
37
+ });
38
+
39
+ if (!user) {
40
+ throw new Error('User not found');
41
+ }
42
+
43
+ const allAchievements = await prisma.achievement.findMany();
44
+ const unlockedAchievements: Achievement[] = [];
45
+
46
+ for (const achievement of allAchievements) {
47
+ if (user.achievements.some(a => a.id === achievement.id)) {
48
+ continue;
49
+ }
50
+
51
+ if (await checkAchievementRequirements(user, achievement)) {
52
+ await unlockAchievement(userId, achievement);
53
+ unlockedAchievements.push(achievement);
54
+ }
55
+ }
56
+
57
+ return unlockedAchievements;
58
+ } catch (error) {
59
+ console.error('Error checking achievements:', error);
60
+ return [];
61
+ }
62
+ }
63
+
64
+ async function checkAchievementRequirements(
65
+ user: User & { contributions: Contribution[]; nfts: NFT[] },
66
+ achievement: Achievement
67
+ ): Promise<boolean> {
68
+ for (const requirement of achievement.requirements) {
69
+ switch (requirement.type) {
70
+ case AchievementType.CONTRIBUTION_COUNT:
71
+ if (user.contributions.length < requirement.value) {
72
+ return false;
73
+ }
74
+ break;
75
+ case AchievementType.POINTS_TOTAL:
76
+ if (user.points < requirement.value) {
77
+ return false;
78
+ }
79
+ break;
80
+ case AchievementType.NFT_COLLECTION:
81
+ if (user.nfts.length < requirement.value) {
82
+ return false;
83
+ }
84
+ break;
85
+ case AchievementType.LEVEL_REACHED:
86
+ if (user.level < requirement.value) {
87
+ return false;
88
+ }
89
+ break;
90
+ }
91
+ }
92
+ return true;
93
+ }
94
+
95
+ async function unlockAchievement(userId: string, achievement: Achievement): Promise<void> {
96
+ try {
97
+ await prisma.userAchievement.create({
98
+ data: {
99
+ userId,
100
+ achievementId: achievement.id,
101
+ },
102
+ });
103
+
104
+ await createNotification(
105
+ userId,
106
+ NotificationType.NEW_ACHIEVEMENT,
107
+ `Congratulations! You've unlocked the achievement: ${achievement.name}`,
108
+ { achievementId: achievement.id }
109
+ );
110
+ } catch (error) {
111
+ console.error('Error unlocking achievement:', error);
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ export async function getUserAchievements(userId: string): Promise<Achievement[]> {
117
+ try {
118
+ const userAchievements = await prisma.userAchievement.findMany({
119
+ where: { userId },
120
+ include: { achievement: true },
121
+ });
122
+
123
+ return userAchievements.map(ua => ua.achievement);
124
+ } catch (error) {
125
+ console.error('Error getting user achievements:', error);
126
+ return [];
127
+ }
128
+ }
129
+
130
+ export async function getAchievementProgress(
131
+ userId: string,
132
+ achievementId: string
133
+ ): Promise<{ progress: number; total: number }> {
134
+ try {
135
+ const user = await prisma.user.findUnique({
136
+ where: { id: userId },
137
+ include: {
138
+ contributions: true,
139
+ nfts: true,
140
+ },
141
+ });
142
+
143
+ if (!user) {
144
+ throw new Error('User not found');
145
+ }
146
+
147
+ const achievement = await prisma.achievement.findUnique({
148
+ where: { id: achievementId },
149
+ });
150
+
151
+ if (!achievement) {
152
+ throw new Error('Achievement not found');
153
+ }
154
+
155
+ let progress = 0;
156
+ let total = 0;
157
+
158
+ for (const requirement of achievement.requirements) {
159
+ switch (requirement.type) {
160
+ case AchievementType.CONTRIBUTION_COUNT:
161
+ progress += user.contributions.length;
162
+ total += requirement.value;
163
+ break;
164
+ case AchievementType.POINTS_TOTAL:
165
+ progress += user.points;
166
+ total += requirement.value;
167
+ break;
168
+ case AchievementType.NFT_COLLECTION:
169
+ progress += user.nfts.length;
170
+ total += requirement.value;
171
+ break;
172
+ case AchievementType.LEVEL_REACHED:
173
+ progress += user.level;
174
+ total += requirement.value;
175
+ break;
176
+ }
177
+ }
178
+
179
+ return { progress, total };
180
+ } catch (error) {
181
+ console.error('Error getting achievement progress:', error);
182
+ throw error;
183
+ }
184
+ }
src/lib/ai.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Configuration, OpenAIApi } from 'openai';
2
+
3
+ const configuration = new Configuration({
4
+ apiKey: process.env.OPENAI_API_KEY,
5
+ });
6
+
7
+ const openai = new OpenAIApi(configuration);
8
+
9
+ export async function generateCodeReview(code: string): Promise<string> {
10
+ try {
11
+ const response = await openai.createCompletion({
12
+ model: 'text-davinci-003',
13
+ prompt: `Please review the following code and provide suggestions for improvement:\n\n${code}`,
14
+ max_tokens: 500,
15
+ temperature: 0.7,
16
+ });
17
+
18
+ return response.data.choices[0].text || 'No review available';
19
+ } catch (error) {
20
+ console.error('Error generating code review:', error);
21
+ return 'Error generating code review';
22
+ }
23
+ }
24
+
25
+ export async function generateCommitMessage(diff: string): Promise<string> {
26
+ try {
27
+ const response = await openai.createCompletion({
28
+ model: 'text-davinci-003',
29
+ prompt: `Generate a concise commit message for the following changes:\n\n${diff}`,
30
+ max_tokens: 100,
31
+ temperature: 0.7,
32
+ });
33
+
34
+ return response.data.choices[0].text || 'Update';
35
+ } catch (error) {
36
+ console.error('Error generating commit message:', error);
37
+ return 'Update';
38
+ }
39
+ }
40
+
41
+ export async function generateCodeSuggestion(prompt: string): Promise<string> {
42
+ try {
43
+ const response = await openai.createCompletion({
44
+ model: 'text-davinci-003',
45
+ prompt: `Generate code based on the following description:\n\n${prompt}`,
46
+ max_tokens: 500,
47
+ temperature: 0.7,
48
+ });
49
+
50
+ return response.data.choices[0].text || 'No suggestion available';
51
+ } catch (error) {
52
+ console.error('Error generating code suggestion:', error);
53
+ return 'Error generating code suggestion';
54
+ }
55
+ }
src/lib/analytics.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { Contribution, ContributionType, User } from '../types/incentive';
3
+
4
+ export async function trackContribution(
5
+ userId: string,
6
+ type: ContributionType,
7
+ points: number,
8
+ description: string
9
+ ): Promise<Contribution> {
10
+ try {
11
+ const contribution = await prisma.contribution.create({
12
+ data: {
13
+ userId,
14
+ type,
15
+ points,
16
+ description,
17
+ status: 'PENDING',
18
+ },
19
+ include: {
20
+ user: true,
21
+ },
22
+ });
23
+
24
+ await updateUserPoints(userId, points);
25
+
26
+ return contribution;
27
+ } catch (error) {
28
+ console.error('Error tracking contribution:', error);
29
+ throw error;
30
+ }
31
+ }
32
+
33
+ export async function updateUserPoints(userId: string, points: number): Promise<User> {
34
+ try {
35
+ const user = await prisma.user.update({
36
+ where: { id: userId },
37
+ data: {
38
+ points: {
39
+ increment: points,
40
+ },
41
+ },
42
+ });
43
+
44
+ await updateUserLevel(userId);
45
+
46
+ return user;
47
+ } catch (error) {
48
+ console.error('Error updating user points:', error);
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ export async function updateUserLevel(userId: string): Promise<User> {
54
+ try {
55
+ const user = await prisma.user.findUnique({
56
+ where: { id: userId },
57
+ });
58
+
59
+ if (!user) {
60
+ throw new Error('User not found');
61
+ }
62
+
63
+ const level = Math.floor(Math.sqrt(user.points / 100)) + 1;
64
+
65
+ return await prisma.user.update({
66
+ where: { id: userId },
67
+ data: { level },
68
+ });
69
+ } catch (error) {
70
+ console.error('Error updating user level:', error);
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ export async function getLeaderboard(): Promise<User[]> {
76
+ try {
77
+ const users = await prisma.user.findMany({
78
+ orderBy: {
79
+ points: 'desc',
80
+ },
81
+ take: 100,
82
+ });
83
+
84
+ return users.map((user, index) => ({
85
+ ...user,
86
+ rank: index + 1,
87
+ }));
88
+ } catch (error) {
89
+ console.error('Error getting leaderboard:', error);
90
+ return [];
91
+ }
92
+ }
93
+
94
+ export async function getUserStats(userId: string): Promise<{
95
+ totalPoints: number;
96
+ contributionCount: number;
97
+ nftCount: number;
98
+ rank: number;
99
+ }> {
100
+ try {
101
+ const user = await prisma.user.findUnique({
102
+ where: { id: userId },
103
+ include: {
104
+ contributions: true,
105
+ nfts: true,
106
+ },
107
+ });
108
+
109
+ if (!user) {
110
+ throw new Error('User not found');
111
+ }
112
+
113
+ const leaderboard = await getLeaderboard();
114
+ const rank = leaderboard.findIndex(u => u.id === userId) + 1;
115
+
116
+ return {
117
+ totalPoints: user.points,
118
+ contributionCount: user.contributions.length,
119
+ nftCount: user.nfts.length,
120
+ rank,
121
+ };
122
+ } catch (error) {
123
+ console.error('Error getting user stats:', error);
124
+ throw error;
125
+ }
126
+ }
src/lib/auth.ts ADDED
@@ -0,0 +1 @@
 
 
1
+
src/lib/blockchain.ts ADDED
@@ -0,0 +1 @@
 
 
1
+
src/lib/cache.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CACHE_CONFIG } from './config';
2
+
3
+ interface CacheItem<T> {
4
+ value: T;
5
+ expiry: number;
6
+ }
7
+
8
+ class Cache {
9
+ private static instance: Cache;
10
+ private cache: Map<string, CacheItem<any>>;
11
+ private maxItems: number;
12
+
13
+ private constructor() {
14
+ this.cache = new Map();
15
+ this.maxItems = CACHE_CONFIG.MAX_ITEMS;
16
+ }
17
+
18
+ public static getInstance(): Cache {
19
+ if (!Cache.instance) {
20
+ Cache.instance = new Cache();
21
+ }
22
+ return Cache.instance;
23
+ }
24
+
25
+ public set<T>(key: string, value: T, ttl: number = CACHE_CONFIG.TTL): void {
26
+ if (this.cache.size >= this.maxItems) {
27
+ this.evictExpiredItems();
28
+ if (this.cache.size >= this.maxItems) {
29
+ const oldestKey = this.cache.keys().next().value;
30
+ this.cache.delete(oldestKey);
31
+ }
32
+ }
33
+
34
+ this.cache.set(key, {
35
+ value,
36
+ expiry: Date.now() + ttl,
37
+ });
38
+ }
39
+
40
+ public get<T>(key: string): T | null {
41
+ const item = this.cache.get(key);
42
+ if (!item) {
43
+ return null;
44
+ }
45
+
46
+ if (Date.now() > item.expiry) {
47
+ this.cache.delete(key);
48
+ return null;
49
+ }
50
+
51
+ return item.value as T;
52
+ }
53
+
54
+ public delete(key: string): void {
55
+ this.cache.delete(key);
56
+ }
57
+
58
+ public clear(): void {
59
+ this.cache.clear();
60
+ }
61
+
62
+ public has(key: string): boolean {
63
+ const item = this.cache.get(key);
64
+ if (!item) {
65
+ return false;
66
+ }
67
+
68
+ if (Date.now() > item.expiry) {
69
+ this.cache.delete(key);
70
+ return false;
71
+ }
72
+
73
+ return true;
74
+ }
75
+
76
+ public size(): number {
77
+ this.evictExpiredItems();
78
+ return this.cache.size;
79
+ }
80
+
81
+ private evictExpiredItems(): void {
82
+ const now = Date.now();
83
+ for (const [key, item] of this.cache.entries()) {
84
+ if (now > item.expiry) {
85
+ this.cache.delete(key);
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ export const cache = Cache.getInstance();
92
+
93
+ export async function withCache<T>(
94
+ key: string,
95
+ fn: () => Promise<T>,
96
+ ttl: number = CACHE_CONFIG.TTL
97
+ ): Promise<T> {
98
+ const cachedValue = cache.get<T>(key);
99
+ if (cachedValue !== null) {
100
+ return cachedValue;
101
+ }
102
+
103
+ const value = await fn();
104
+ cache.set(key, value, ttl);
105
+ return value;
106
+ }
107
+
108
+ export function invalidateCache(pattern: string): void {
109
+ for (const key of cache['cache'].keys()) {
110
+ if (key.includes(pattern)) {
111
+ cache.delete(key);
112
+ }
113
+ }
114
+ }
115
+
116
+ export function clearCache(): void {
117
+ cache.clear();
118
+ }
src/lib/config.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const APP_NAME = 'AI Tree Portal';
2
+ export const APP_DESCRIPTION = 'AI Tree Portal은 개발자들을 위한 커뮤니티 플랫폼입니다.';
3
+
4
+ export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000/api';
5
+ export const WS_BASE_URL = process.env.NEXT_PUBLIC_WS_BASE_URL || 'ws://localhost:3000';
6
+
7
+ export const POINTS_CONFIG = {
8
+ POST_CREATION: 10,
9
+ COMMENT: 5,
10
+ REVIEW: 15,
11
+ BUG_REPORT: 20,
12
+ FEATURE_SUGGESTION: 15,
13
+ CODE_CONTRIBUTION: 30,
14
+ };
15
+
16
+ export const LEVEL_CONFIG = {
17
+ BASE_POINTS: 100,
18
+ LEVEL_MULTIPLIER: 1.5,
19
+ MAX_LEVEL: 100,
20
+ };
21
+
22
+ export const NFT_CONFIG = {
23
+ MINT_COST: 1000,
24
+ RARITY_WEIGHTS: {
25
+ common: 0.6,
26
+ rare: 0.25,
27
+ epic: 0.1,
28
+ legendary: 0.05,
29
+ },
30
+ };
31
+
32
+ export const ACHIEVEMENT_CONFIG = {
33
+ CONTRIBUTION_MILESTONES: [10, 50, 100, 500, 1000],
34
+ POINTS_MILESTONES: [1000, 5000, 10000, 50000, 100000],
35
+ NFT_MILESTONES: [1, 5, 10, 25, 50],
36
+ LEVEL_MILESTONES: [10, 20, 30, 40, 50],
37
+ };
38
+
39
+ export const RATE_LIMIT_CONFIG = {
40
+ MAX_REQUESTS: 100,
41
+ WINDOW_MS: 15 * 60 * 1000, // 15 minutes
42
+ };
43
+
44
+ export const CACHE_CONFIG = {
45
+ TTL: 5 * 60 * 1000, // 5 minutes
46
+ MAX_ITEMS: 1000,
47
+ };
48
+
49
+ export const SECURITY_CONFIG = {
50
+ PASSWORD_MIN_LENGTH: 8,
51
+ PASSWORD_REQUIREMENTS: {
52
+ UPPERCASE: true,
53
+ LOWERCASE: true,
54
+ NUMBERS: true,
55
+ SPECIAL_CHARS: true,
56
+ },
57
+ SESSION_DURATION: 24 * 60 * 60 * 1000, // 24 hours
58
+ TOKEN_EXPIRY: 7 * 24 * 60 * 60 * 1000, // 7 days
59
+ };
60
+
61
+ export const UI_CONFIG = {
62
+ PAGINATION_LIMIT: 10,
63
+ MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB
64
+ ALLOWED_FILE_TYPES: ['image/jpeg', 'image/png', 'image/gif'],
65
+ MAX_TITLE_LENGTH: 100,
66
+ MAX_DESCRIPTION_LENGTH: 1000,
67
+ MAX_COMMENT_LENGTH: 500,
68
+ };
69
+
70
+ export const ANALYTICS_CONFIG = {
71
+ ENABLED: process.env.NEXT_PUBLIC_ANALYTICS_ENABLED === 'true',
72
+ TRACKING_ID: process.env.NEXT_PUBLIC_ANALYTICS_TRACKING_ID,
73
+ };
74
+
75
+ export const FEATURE_FLAGS = {
76
+ ENABLE_NFT_MINTING: process.env.NEXT_PUBLIC_ENABLE_NFT_MINTING === 'true',
77
+ ENABLE_ACHIEVEMENTS: process.env.NEXT_PUBLIC_ENABLE_ACHIEVEMENTS === 'true',
78
+ ENABLE_LEADERBOARD: process.env.NEXT_PUBLIC_ENABLE_LEADERBOARD === 'true',
79
+ ENABLE_NOTIFICATIONS: process.env.NEXT_PUBLIC_ENABLE_NOTIFICATIONS === 'true',
80
+ };
src/lib/error.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export class AppError extends Error {
2
+ constructor(
3
+ public message: string,
4
+ public statusCode: number = 500,
5
+ public code?: string
6
+ ) {
7
+ super(message);
8
+ this.name = 'AppError';
9
+ }
10
+ }
11
+
12
+ export class ValidationError extends AppError {
13
+ constructor(message: string) {
14
+ super(message, 400, 'VALIDATION_ERROR');
15
+ this.name = 'ValidationError';
16
+ }
17
+ }
18
+
19
+ export class AuthenticationError extends AppError {
20
+ constructor(message: string = '인증에 실패했습니다.') {
21
+ super(message, 401, 'AUTHENTICATION_ERROR');
22
+ this.name = 'AuthenticationError';
23
+ }
24
+ }
25
+
26
+ export class AuthorizationError extends AppError {
27
+ constructor(message: string = '권한이 없습니다.') {
28
+ super(message, 403, 'AUTHORIZATION_ERROR');
29
+ this.name = 'AuthorizationError';
30
+ }
31
+ }
32
+
33
+ export class NotFoundError extends AppError {
34
+ constructor(message: string = '요청한 리소스를 찾을 수 없습니다.') {
35
+ super(message, 404, 'NOT_FOUND_ERROR');
36
+ this.name = 'NotFoundError';
37
+ }
38
+ }
39
+
40
+ export class ConflictError extends AppError {
41
+ constructor(message: string = '이미 존재하는 리소스입니다.') {
42
+ super(message, 409, 'CONFLICT_ERROR');
43
+ this.name = 'ConflictError';
44
+ }
45
+ }
46
+
47
+ export class DatabaseError extends AppError {
48
+ constructor(message: string = '데이터베이스 오류가 발생했습니다.') {
49
+ super(message, 500, 'DATABASE_ERROR');
50
+ this.name = 'DatabaseError';
51
+ }
52
+ }
53
+
54
+ export class ExternalServiceError extends AppError {
55
+ constructor(message: string = '외부 서비스 오류가 발생했습니다.') {
56
+ super(message, 502, 'EXTERNAL_SERVICE_ERROR');
57
+ this.name = 'ExternalServiceError';
58
+ }
59
+ }
60
+
61
+ export function handleError(error: unknown): AppError {
62
+ if (error instanceof AppError) {
63
+ return error;
64
+ }
65
+
66
+ if (error instanceof Error) {
67
+ return new AppError(error.message);
68
+ }
69
+
70
+ return new AppError('알 수 없는 오류가 발생했습니다.');
71
+ }
72
+
73
+ export function isAppError(error: unknown): error is AppError {
74
+ return error instanceof AppError;
75
+ }
76
+
77
+ export function isValidationError(error: unknown): error is ValidationError {
78
+ return error instanceof ValidationError;
79
+ }
80
+
81
+ export function isAuthenticationError(error: unknown): error is AuthenticationError {
82
+ return error instanceof AuthenticationError;
83
+ }
84
+
85
+ export function isAuthorizationError(error: unknown): error is AuthorizationError {
86
+ return error instanceof AuthorizationError;
87
+ }
88
+
89
+ export function isNotFoundError(error: unknown): error is NotFoundError {
90
+ return error instanceof NotFoundError;
91
+ }
92
+
93
+ export function isConflictError(error: unknown): error is ConflictError {
94
+ return error instanceof ConflictError;
95
+ }
96
+
97
+ export function isDatabaseError(error: unknown): error is DatabaseError {
98
+ return error instanceof DatabaseError;
99
+ }
100
+
101
+ export function isExternalServiceError(error: unknown): error is ExternalServiceError {
102
+ return error instanceof ExternalServiceError;
103
+ }
src/lib/git.ts CHANGED
@@ -1,5 +1,10 @@
1
  'use client';
2
 
 
 
 
 
 
3
  export interface Commit {
4
  hash: string;
5
  message: string;
@@ -15,39 +20,23 @@ export interface FileDiff {
15
 
16
  export async function getCommitHistory(): Promise<Commit[]> {
17
  try {
18
- const response = await fetch('/api/git?action=history');
19
- if (!response.ok) {
20
- throw new Error('커밋 이력 조회 실패');
21
- }
22
- const data = await response.json();
23
- return data.commits;
24
  } catch (error) {
25
- console.error('커밋 이력 조회 실패:', error);
26
  return [];
27
  }
28
  }
29
 
30
  export async function rollbackToCommit(commitHash: string): Promise<boolean> {
31
  try {
32
- const response = await fetch('/api/git', {
33
- method: 'POST',
34
- headers: {
35
- 'Content-Type': 'application/json',
36
- },
37
- body: JSON.stringify({
38
- action: 'rollback',
39
- commitHash,
40
- }),
41
- });
42
-
43
- if (!response.ok) {
44
- throw new Error('롤백 실패');
45
- }
46
-
47
- const data = await response.json();
48
- return data.success;
49
  } catch (error) {
50
- console.error('롤백 실패:', error);
51
  return false;
52
  }
53
  }
@@ -56,20 +45,13 @@ export async function getFileDiff(
56
  commitHash1: string,
57
  commitHash2: string,
58
  filePath: string
59
- ): Promise<FileDiff> {
60
  try {
61
- const response = await fetch(
62
- `/api/git?action=diff&hash1=${commitHash1}&hash2=${commitHash2}&file=${encodeURIComponent(filePath)}`
63
- );
64
-
65
- if (!response.ok) {
66
- throw new Error('파일 변경사항 비교 실패');
67
- }
68
-
69
- return await response.json();
70
  } catch (error) {
71
- console.error('파일 변경사항 비교 실패:', error);
72
- throw error;
73
  }
74
  }
75
 
 
1
  'use client';
2
 
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
  export interface Commit {
9
  hash: string;
10
  message: string;
 
20
 
21
  export async function getCommitHistory(): Promise<Commit[]> {
22
  try {
23
+ const { stdout } = await execAsync('git log --pretty=format:"%H|%s|%an|%ad" --date=iso');
24
+ return stdout.split('\n').map(line => {
25
+ const [hash, message, author, date] = line.split('|');
26
+ return { hash, message, author, date };
27
+ });
 
28
  } catch (error) {
29
+ console.error('Error getting commit history:', error);
30
  return [];
31
  }
32
  }
33
 
34
  export async function rollbackToCommit(commitHash: string): Promise<boolean> {
35
  try {
36
+ await execAsync(`git reset --hard ${commitHash}`);
37
+ return true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  } catch (error) {
39
+ console.error('Error rolling back to commit:', error);
40
  return false;
41
  }
42
  }
 
45
  commitHash1: string,
46
  commitHash2: string,
47
  filePath: string
48
+ ): Promise<string> {
49
  try {
50
+ const { stdout } = await execAsync(`git diff ${commitHash1} ${commitHash2} -- ${filePath}`);
51
+ return stdout;
 
 
 
 
 
 
 
52
  } catch (error) {
53
+ console.error('Error getting file diff:', error);
54
+ return '';
55
  }
56
  }
57
 
src/lib/logger.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AppError } from './error';
2
+
3
+ export enum LogLevel {
4
+ DEBUG = 'DEBUG',
5
+ INFO = 'INFO',
6
+ WARN = 'WARN',
7
+ ERROR = 'ERROR',
8
+ }
9
+
10
+ export interface LogEntry {
11
+ timestamp: string;
12
+ level: LogLevel;
13
+ message: string;
14
+ error?: AppError;
15
+ metadata?: Record<string, any>;
16
+ }
17
+
18
+ class Logger {
19
+ private static instance: Logger;
20
+ private logLevel: LogLevel;
21
+
22
+ private constructor() {
23
+ this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG;
24
+ }
25
+
26
+ public static getInstance(): Logger {
27
+ if (!Logger.instance) {
28
+ Logger.instance = new Logger();
29
+ }
30
+ return Logger.instance;
31
+ }
32
+
33
+ public setLogLevel(level: LogLevel): void {
34
+ this.logLevel = level;
35
+ }
36
+
37
+ private shouldLog(level: LogLevel): boolean {
38
+ const levels = Object.values(LogLevel);
39
+ return levels.indexOf(level) >= levels.indexOf(this.logLevel);
40
+ }
41
+
42
+ private formatLog(level: LogLevel, message: string, error?: AppError, metadata?: Record<string, any>): LogEntry {
43
+ return {
44
+ timestamp: new Date().toISOString(),
45
+ level,
46
+ message,
47
+ error,
48
+ metadata,
49
+ };
50
+ }
51
+
52
+ private log(level: LogLevel, message: string, error?: AppError, metadata?: Record<string, any>): void {
53
+ if (!this.shouldLog(level)) {
54
+ return;
55
+ }
56
+
57
+ const logEntry = this.formatLog(level, message, error, metadata);
58
+
59
+ switch (level) {
60
+ case LogLevel.DEBUG:
61
+ console.debug(JSON.stringify(logEntry));
62
+ break;
63
+ case LogLevel.INFO:
64
+ console.info(JSON.stringify(logEntry));
65
+ break;
66
+ case LogLevel.WARN:
67
+ console.warn(JSON.stringify(logEntry));
68
+ break;
69
+ case LogLevel.ERROR:
70
+ console.error(JSON.stringify(logEntry));
71
+ break;
72
+ }
73
+ }
74
+
75
+ public debug(message: string, metadata?: Record<string, any>): void {
76
+ this.log(LogLevel.DEBUG, message, undefined, metadata);
77
+ }
78
+
79
+ public info(message: string, metadata?: Record<string, any>): void {
80
+ this.log(LogLevel.INFO, message, undefined, metadata);
81
+ }
82
+
83
+ public warn(message: string, error?: AppError, metadata?: Record<string, any>): void {
84
+ this.log(LogLevel.WARN, message, error, metadata);
85
+ }
86
+
87
+ public error(message: string, error?: AppError, metadata?: Record<string, any>): void {
88
+ this.log(LogLevel.ERROR, message, error, metadata);
89
+ }
90
+ }
91
+
92
+ export const logger = Logger.getInstance();
src/lib/middleware.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextApiRequest, NextApiResponse } from 'next';
2
+ import { AppError, handleError } from './error';
3
+ import { logger } from './logger';
4
+ import { validateEmail, validatePassword, validateUsername } from './validation';
5
+
6
+ export type Middleware = (
7
+ req: NextApiRequest,
8
+ res: NextApiResponse,
9
+ next: () => void
10
+ ) => void | Promise<void>;
11
+
12
+ export function errorHandler(): Middleware {
13
+ return async (req, res, next) => {
14
+ try {
15
+ await next();
16
+ } catch (error) {
17
+ const appError = handleError(error);
18
+ logger.error('Error occurred', appError, { path: req.url, method: req.method });
19
+
20
+ res.status(appError.statusCode).json({
21
+ error: {
22
+ message: appError.message,
23
+ code: appError.code,
24
+ },
25
+ });
26
+ }
27
+ };
28
+ }
29
+
30
+ export function loggerMiddleware(): Middleware {
31
+ return (req, res, next) => {
32
+ const start = Date.now();
33
+ res.on('finish', () => {
34
+ const duration = Date.now() - start;
35
+ logger.info('Request completed', {
36
+ method: req.method,
37
+ path: req.url,
38
+ status: res.statusCode,
39
+ duration: `${duration}ms`,
40
+ });
41
+ });
42
+ next();
43
+ };
44
+ }
45
+
46
+ export function validateAuth(): Middleware {
47
+ return (req, res, next) => {
48
+ const token = req.headers.authorization?.split(' ')[1];
49
+ if (!token) {
50
+ throw new AppError('인증 토큰이 필요합니다.', 401);
51
+ }
52
+ // TODO: 토큰 검증 로직 구현
53
+ next();
54
+ };
55
+ }
56
+
57
+ export function validateUserInput(): Middleware {
58
+ return (req, res, next) => {
59
+ if (req.method === 'POST' || req.method === 'PUT') {
60
+ const { email, password, username } = req.body;
61
+
62
+ if (email && !validateEmail(email)) {
63
+ throw new AppError('유효하지 않은 이메일 형식입니다.', 400);
64
+ }
65
+
66
+ if (password && !validatePassword(password)) {
67
+ throw new AppError(
68
+ '비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.',
69
+ 400
70
+ );
71
+ }
72
+
73
+ if (username && !validateUsername(username)) {
74
+ throw new AppError(
75
+ '사용자 이름은 3-20자의 영문자, 숫자, 언더스코어만 사용할 수 있습니다.',
76
+ 400
77
+ );
78
+ }
79
+ }
80
+ next();
81
+ };
82
+ }
83
+
84
+ export function corsMiddleware(): Middleware {
85
+ return (req, res, next) => {
86
+ res.setHeader('Access-Control-Allow-Origin', '*');
87
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
88
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
89
+
90
+ if (req.method === 'OPTIONS') {
91
+ res.status(200).end();
92
+ return;
93
+ }
94
+
95
+ next();
96
+ };
97
+ }
98
+
99
+ export function rateLimitMiddleware(maxRequests: number, windowMs: number): Middleware {
100
+ const requests = new Map<string, { count: number; resetTime: number }>();
101
+
102
+ return (req, res, next) => {
103
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
104
+ if (!ip) {
105
+ next();
106
+ return;
107
+ }
108
+
109
+ const now = Date.now();
110
+ const userRequests = requests.get(ip as string);
111
+
112
+ if (!userRequests || now > userRequests.resetTime) {
113
+ requests.set(ip as string, {
114
+ count: 1,
115
+ resetTime: now + windowMs,
116
+ });
117
+ next();
118
+ return;
119
+ }
120
+
121
+ if (userRequests.count >= maxRequests) {
122
+ throw new AppError('요청 횟수가 초과되었습니다. 잠시 후 다시 시도해주세요.', 429);
123
+ }
124
+
125
+ userRequests.count++;
126
+ next();
127
+ };
128
+ }
129
+
130
+ export function applyMiddlewares(...middlewares: Middleware[]): Middleware {
131
+ return async (req, res, next) => {
132
+ let index = 0;
133
+
134
+ const runNext = async () => {
135
+ if (index < middlewares.length) {
136
+ const middleware = middlewares[index++];
137
+ await middleware(req, res, runNext);
138
+ } else {
139
+ await next();
140
+ }
141
+ };
142
+
143
+ await runNext();
144
+ };
145
+ }
src/lib/notification.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from './prisma';
2
+ import { Contribution, NFT, User } from '../types/incentive';
3
+
4
+ export interface Notification {
5
+ id: string;
6
+ userId: string;
7
+ type: NotificationType;
8
+ message: string;
9
+ read: boolean;
10
+ createdAt: string;
11
+ metadata?: any;
12
+ }
13
+
14
+ export enum NotificationType {
15
+ CONTRIBUTION_APPROVED = 'CONTRIBUTION_APPROVED',
16
+ CONTRIBUTION_REJECTED = 'CONTRIBUTION_REJECTED',
17
+ NFT_MINTED = 'NFT_MINTED',
18
+ LEVEL_UP = 'LEVEL_UP',
19
+ RANK_CHANGE = 'RANK_CHANGE',
20
+ NEW_ACHIEVEMENT = 'NEW_ACHIEVEMENT',
21
+ }
22
+
23
+ export async function createNotification(
24
+ userId: string,
25
+ type: NotificationType,
26
+ message: string,
27
+ metadata?: any
28
+ ): Promise<Notification> {
29
+ try {
30
+ return await prisma.notification.create({
31
+ data: {
32
+ userId,
33
+ type,
34
+ message,
35
+ read: false,
36
+ metadata,
37
+ },
38
+ });
39
+ } catch (error) {
40
+ console.error('Error creating notification:', error);
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ export async function getUnreadNotifications(userId: string): Promise<Notification[]> {
46
+ try {
47
+ return await prisma.notification.findMany({
48
+ where: {
49
+ userId,
50
+ read: false,
51
+ },
52
+ orderBy: {
53
+ createdAt: 'desc',
54
+ },
55
+ });
56
+ } catch (error) {
57
+ console.error('Error getting unread notifications:', error);
58
+ return [];
59
+ }
60
+ }
61
+
62
+ export async function markNotificationAsRead(notificationId: string): Promise<Notification> {
63
+ try {
64
+ return await prisma.notification.update({
65
+ where: { id: notificationId },
66
+ data: { read: true },
67
+ });
68
+ } catch (error) {
69
+ console.error('Error marking notification as read:', error);
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ export async function markAllNotificationsAsRead(userId: string): Promise<void> {
75
+ try {
76
+ await prisma.notification.updateMany({
77
+ where: { userId, read: false },
78
+ data: { read: true },
79
+ });
80
+ } catch (error) {
81
+ console.error('Error marking all notifications as read:', error);
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ export async function notifyContributionApproval(contribution: Contribution): Promise<void> {
87
+ const message = `Your contribution "${contribution.description}" has been approved! You earned ${contribution.points} points.`;
88
+ await createNotification(
89
+ contribution.userId,
90
+ NotificationType.CONTRIBUTION_APPROVED,
91
+ message,
92
+ { contributionId: contribution.id }
93
+ );
94
+ }
95
+
96
+ export async function notifyNFTMinted(user: User, nft: NFT): Promise<void> {
97
+ const message = `Congratulations! You've minted a new NFT: ${nft.title}`;
98
+ await createNotification(user.id, NotificationType.NFT_MINTED, message, { nftId: nft.id });
99
+ }
100
+
101
+ export async function notifyLevelUp(user: User, newLevel: number): Promise<void> {
102
+ const message = `Congratulations! You've reached level ${newLevel}!`;
103
+ await createNotification(user.id, NotificationType.LEVEL_UP, message, { level: newLevel });
104
+ }
105
+
106
+ export async function notifyRankChange(user: User, oldRank: number, newRank: number): Promise<void> {
107
+ const message = `Your rank has changed from ${oldRank} to ${newRank}!`;
108
+ await createNotification(user.id, NotificationType.RANK_CHANGE, message, {
109
+ oldRank,
110
+ newRank,
111
+ });
112
+ }
src/lib/utils.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ContributionType } from '../types/incentive';
2
+
3
+ export function formatDate(date: Date): string {
4
+ return new Intl.DateTimeFormat('ko-KR', {
5
+ year: 'numeric',
6
+ month: 'long',
7
+ day: 'numeric',
8
+ hour: '2-digit',
9
+ minute: '2-digit',
10
+ }).format(date);
11
+ }
12
+
13
+ export function formatPoints(points: number): string {
14
+ return new Intl.NumberFormat('ko-KR').format(points);
15
+ }
16
+
17
+ export function getContributionTypeLabel(type: ContributionType): string {
18
+ const labels: Record<ContributionType, string> = {
19
+ [ContributionType.POST_CREATION]: '게시물 작성',
20
+ [ContributionType.COMMENT]: '댓글 작성',
21
+ [ContributionType.REVIEW]: '리뷰 작성',
22
+ [ContributionType.BUG_REPORT]: '버그 리포트',
23
+ [ContributionType.FEATURE_SUGGESTION]: '기능 제안',
24
+ [ContributionType.CODE_CONTRIBUTION]: '코드 기여',
25
+ };
26
+ return labels[type];
27
+ }
28
+
29
+ export function getLevelTitle(level: number): string {
30
+ if (level < 10) return '초보자';
31
+ if (level < 20) return '견습생';
32
+ if (level < 30) return '전문가';
33
+ if (level < 40) return '마스터';
34
+ if (level < 50) return '그랜드마스터';
35
+ return '레전드';
36
+ }
37
+
38
+ export function getRarityColor(rarity: string): string {
39
+ const colors: Record<string, string> = {
40
+ common: '#808080',
41
+ rare: '#4169E1',
42
+ epic: '#9932CC',
43
+ legendary: '#FFD700',
44
+ };
45
+ return colors[rarity] || '#808080';
46
+ }
47
+
48
+ export function calculateLevelProgress(points: number): { current: number; next: number; progress: number } {
49
+ const currentLevel = Math.floor(Math.sqrt(points / 100)) + 1;
50
+ const currentLevelPoints = Math.pow(currentLevel - 1, 2) * 100;
51
+ const nextLevelPoints = Math.pow(currentLevel, 2) * 100;
52
+ const progress = ((points - currentLevelPoints) / (nextLevelPoints - currentLevelPoints)) * 100;
53
+
54
+ return {
55
+ current: currentLevel,
56
+ next: currentLevel + 1,
57
+ progress,
58
+ };
59
+ }
60
+
61
+ export function generateRandomString(length: number): string {
62
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
63
+ let result = '';
64
+ for (let i = 0; i < length; i++) {
65
+ result += characters.charAt(Math.floor(Math.random() * characters.length));
66
+ }
67
+ return result;
68
+ }
69
+
70
+ export function truncateText(text: string, maxLength: number): string {
71
+ if (text.length <= maxLength) return text;
72
+ return text.slice(0, maxLength) + '...';
73
+ }
74
+
75
+ export function debounce<T extends (...args: any[]) => any>(
76
+ func: T,
77
+ wait: number
78
+ ): (...args: Parameters<T>) => void {
79
+ let timeout: NodeJS.Timeout;
80
+ return (...args: Parameters<T>) => {
81
+ clearTimeout(timeout);
82
+ timeout = setTimeout(() => func(...args), wait);
83
+ };
84
+ }
85
+
86
+ export function throttle<T extends (...args: any[]) => any>(
87
+ func: T,
88
+ limit: number
89
+ ): (...args: Parameters<T>) => void {
90
+ let inThrottle: boolean;
91
+ return (...args: Parameters<T>) => {
92
+ if (!inThrottle) {
93
+ func(...args);
94
+ inThrottle = true;
95
+ setTimeout(() => (inThrottle = false), limit);
96
+ }
97
+ };
98
+ }
99
+
100
+ export function formatFileSize(bytes: number): string {
101
+ if (bytes === 0) return '0 Bytes';
102
+ const k = 1024;
103
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
104
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
105
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
106
+ }
107
+
108
+ export function isValidUrl(url: string): boolean {
109
+ try {
110
+ new URL(url);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ export function getInitials(name: string): string {
118
+ return name
119
+ .split(' ')
120
+ .map(word => word[0])
121
+ .join('')
122
+ .toUpperCase();
123
+ }
124
+
125
+ export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
126
+ return array.reduce((result, item) => {
127
+ const groupKey = String(item[key]);
128
+ if (!result[groupKey]) {
129
+ result[groupKey] = [];
130
+ }
131
+ result[groupKey].push(item);
132
+ return result;
133
+ }, {} as Record<string, T[]>);
134
+ }
src/lib/validation.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { z } from 'zod';
 
2
 
3
  export const PostSchema = z.object({
4
  title: z.string().min(1, '제목은 필수입니다.'),
@@ -29,4 +30,106 @@ export const FileDiffSchema = z.object({
29
  export type Post = z.infer<typeof PostSchema>;
30
  export type Commit = z.infer<typeof CommitSchema>;
31
  export type SearchQuery = z.infer<typeof SearchQuerySchema>;
32
- export type FileDiff = z.infer<typeof FileDiffSchema>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { z } from 'zod';
2
+ import { ContributionType } from '../types/incentive';
3
 
4
  export const PostSchema = z.object({
5
  title: z.string().min(1, '제목은 필수입니다.'),
 
30
  export type Post = z.infer<typeof PostSchema>;
31
  export type Commit = z.infer<typeof CommitSchema>;
32
  export type SearchQuery = z.infer<typeof SearchQuerySchema>;
33
+ export type FileDiff = z.infer<typeof FileDiffSchema>;
34
+
35
+ export function validateEmail(email: string): boolean {
36
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
37
+ return emailRegex.test(email);
38
+ }
39
+
40
+ export function validatePassword(password: string): boolean {
41
+ // 최소 8자, 대문자, 소문자, 숫자, 특수문자 포함
42
+ const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
43
+ return passwordRegex.test(password);
44
+ }
45
+
46
+ export function validateUsername(username: string): boolean {
47
+ // 영문자, 숫자, 언더스코어만 허용, 3-20자
48
+ const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
49
+ return usernameRegex.test(username);
50
+ }
51
+
52
+ export function validateContributionType(type: string): boolean {
53
+ return Object.values(ContributionType).includes(type as ContributionType);
54
+ }
55
+
56
+ export function validatePoints(points: number): boolean {
57
+ return points > 0 && points <= 1000;
58
+ }
59
+
60
+ export function validateNFTMetadata(metadata: {
61
+ name: string;
62
+ description?: string;
63
+ image: string;
64
+ category: string;
65
+ rarity: string;
66
+ }): boolean {
67
+ if (!metadata.name || metadata.name.length > 100) {
68
+ return false;
69
+ }
70
+
71
+ if (metadata.description && metadata.description.length > 1000) {
72
+ return false;
73
+ }
74
+
75
+ if (!metadata.image || !metadata.image.startsWith('https://')) {
76
+ return false;
77
+ }
78
+
79
+ if (!metadata.category || metadata.category.length > 50) {
80
+ return false;
81
+ }
82
+
83
+ if (!metadata.rarity || !['common', 'rare', 'epic', 'legendary'].includes(metadata.rarity)) {
84
+ return false;
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ export function validateAchievementRequirements(requirements: {
91
+ type: string;
92
+ value: number;
93
+ }[]): boolean {
94
+ if (!Array.isArray(requirements) || requirements.length === 0) {
95
+ return false;
96
+ }
97
+
98
+ for (const req of requirements) {
99
+ if (!req.type || !req.value || req.value <= 0) {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ return true;
105
+ }
106
+
107
+ export function validateNotificationMessage(message: string): boolean {
108
+ return message.length > 0 && message.length <= 500;
109
+ }
110
+
111
+ export function validateUserLevel(level: number): boolean {
112
+ return level > 0 && level <= 100;
113
+ }
114
+
115
+ export function validateUserPoints(points: number): boolean {
116
+ return points >= 0 && points <= 1000000;
117
+ }
118
+
119
+ export function validateUserRank(rank: number): boolean {
120
+ return rank > 0 && rank <= 1000;
121
+ }
122
+
123
+ export function validateUserStats(stats: {
124
+ totalPoints: number;
125
+ contributionCount: number;
126
+ nftCount: number;
127
+ rank: number;
128
+ }): boolean {
129
+ return (
130
+ validateUserPoints(stats.totalPoints) &&
131
+ stats.contributionCount >= 0 &&
132
+ stats.nftCount >= 0 &&
133
+ validateUserRank(stats.rank)
134
+ );
135
+ }
src/types/incentive.ts CHANGED
@@ -30,10 +30,10 @@ export interface NFT {
30
  title: string;
31
  description?: string;
32
  imageUrl: string;
33
- owner: User;
34
- createdAt: string;
35
  category: string;
36
  rarity: string;
 
 
37
  }
38
 
39
  export interface Contribution {
@@ -49,6 +49,6 @@ export interface Contribution {
49
  export interface LeaderboardEntry {
50
  user: User;
51
  totalPoints: number;
52
- rank: number;
53
  contributionCount: number;
 
54
  }
 
30
  title: string;
31
  description?: string;
32
  imageUrl: string;
 
 
33
  category: string;
34
  rarity: string;
35
+ owner: User;
36
+ createdAt: string;
37
  }
38
 
39
  export interface Contribution {
 
49
  export interface LeaderboardEntry {
50
  user: User;
51
  totalPoints: number;
 
52
  contributionCount: number;
53
+ rank: number;
54
  }