Spaces:
Sleeping
Sleeping
Commit
·
3d19d88
1
Parent(s):
ffc6bd8
feat: 인센티브 시스템 구현 및 타입 정의 추가
Browse files- src/graphql/resolvers.ts +42 -38
- src/lib/achievement.ts +184 -0
- src/lib/ai.ts +55 -0
- src/lib/analytics.ts +126 -0
- src/lib/auth.ts +1 -0
- src/lib/blockchain.ts +1 -0
- src/lib/cache.ts +118 -0
- src/lib/config.ts +80 -0
- src/lib/error.ts +103 -0
- src/lib/git.ts +19 -37
- src/lib/logger.ts +92 -0
- src/lib/middleware.ts +145 -0
- src/lib/notification.ts +112 -0
- src/lib/utils.ts +134 -0
- src/lib/validation.ts +104 -1
- src/types/incentive.ts +3 -3
src/graphql/resolvers.ts
CHANGED
|
@@ -15,27 +15,21 @@ interface PostInput {
|
|
| 15 |
}
|
| 16 |
|
| 17 |
type PubSubEvents = {
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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
|
| 35 |
|
| 36 |
const startPingInterval = () => {
|
| 37 |
setInterval(() => {
|
| 38 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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
|
| 226 |
}
|
| 227 |
|
| 228 |
// 리더보드 업데이트
|
| 229 |
const leaderboard = updateLeaderboard();
|
| 230 |
-
await
|
| 231 |
|
| 232 |
-
await
|
| 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
|
| 243 |
return contribution;
|
| 244 |
},
|
| 245 |
},
|
| 246 |
|
| 247 |
Subscription: {
|
| 248 |
postCreated: {
|
| 249 |
-
subscribe: () =>
|
| 250 |
resolve: (payload: { postCreated: Post }) => payload.postCreated
|
| 251 |
},
|
| 252 |
|
| 253 |
postUpdated: {
|
| 254 |
-
subscribe: () =>
|
| 255 |
resolve: (payload: { postUpdated: Post }) => payload.postUpdated
|
| 256 |
},
|
| 257 |
|
| 258 |
postDeleted: {
|
| 259 |
-
subscribe: () =>
|
| 260 |
resolve: (payload: { postDeleted: string }) => payload.postDeleted
|
| 261 |
},
|
| 262 |
|
| 263 |
-
|
| 264 |
-
subscribe: () => pubsub.subscribe('COMMIT_CREATED'),
|
| 265 |
-
resolve: (payload: any) => payload
|
| 266 |
-
},
|
| 267 |
-
|
| 268 |
-
_ping: {
|
| 269 |
subscribe: () => {
|
| 270 |
const interval = setInterval(() => {
|
| 271 |
-
|
| 272 |
}, 5000);
|
| 273 |
|
| 274 |
-
return
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
},
|
| 278 |
userPointsUpdated: {
|
| 279 |
subscribe: (_: any, { userId }: { userId: string }) => ({
|
| 280 |
[Symbol.asyncIterator]: () => ({
|
| 281 |
next: async () => {
|
| 282 |
const user = await new Promise<User>(resolve => {
|
| 283 |
-
|
| 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 |
-
|
| 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 |
-
|
| 316 |
if (contributionStatusChanged.user.id === userId) {
|
| 317 |
resolve(contributionStatusChanged);
|
| 318 |
}
|
|
@@ -324,7 +328,7 @@ export const resolvers = {
|
|
| 324 |
}),
|
| 325 |
},
|
| 326 |
leaderboardUpdated: {
|
| 327 |
-
subscribe: () =>
|
| 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
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
return data.commits;
|
| 24 |
} catch (error) {
|
| 25 |
-
console.error('
|
| 26 |
return [];
|
| 27 |
}
|
| 28 |
}
|
| 29 |
|
| 30 |
export async function rollbackToCommit(commitHash: string): Promise<boolean> {
|
| 31 |
try {
|
| 32 |
-
|
| 33 |
-
|
| 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('
|
| 51 |
return false;
|
| 52 |
}
|
| 53 |
}
|
|
@@ -56,20 +45,13 @@ export async function getFileDiff(
|
|
| 56 |
commitHash1: string,
|
| 57 |
commitHash2: string,
|
| 58 |
filePath: string
|
| 59 |
-
): Promise<
|
| 60 |
try {
|
| 61 |
-
const
|
| 62 |
-
|
| 63 |
-
);
|
| 64 |
-
|
| 65 |
-
if (!response.ok) {
|
| 66 |
-
throw new Error('파일 변경사항 비교 실패');
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
return await response.json();
|
| 70 |
} catch (error) {
|
| 71 |
-
console.error('
|
| 72 |
-
|
| 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 |
}
|