Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b7c3f19
feat: μ‚¬μ—…μž 판맀자 승인 λŒ€κΈ° 관리 API κ΅¬ν˜„ (#451)
minij02 May 16, 2026
9bc1c64
Merge pull request #457 from PromptPlace/feat/#451
minij02 May 16, 2026
63a046a
feat: κ΄€λ¦¬μž 판맀자 λͺ©λ‘ 쑰회 및 검색 API κ΅¬ν˜„ (#452)
minij02 May 16, 2026
e4db147
Merge pull request #458 from PromptPlace/feat/#452
minij02 May 16, 2026
c6b1832
feat: κ΄€λ¦¬μž 판맀자 상세 쑰회 API κ΅¬ν˜„ (#453)
minij02 May 16, 2026
8b006fd
Merge pull request #459 from PromptPlace/feat/#453
minij02 May 16, 2026
032ce1d
feat: κ΄€λ¦¬μž 판맀자 등둝 μ·¨μ†Œ API κ΅¬ν˜„ (#454)
minij02 May 16, 2026
599ecae
Merge pull request #460 from PromptPlace/feat/#454
minij02 May 16, 2026
7100b72
feat: κ΄€λ¦¬μž νšŒμ› κ°€μž… ν˜„ν™© 톡계 API κ΅¬ν˜„ (#455)
minij02 May 16, 2026
45a00f7
Merge pull request #463 from PromptPlace/feat/#455
minij02 May 16, 2026
557837d
feat: κ΄€λ¦¬μž μ‹ κ·œ/맀좜 μƒμœ„ ν”„λ‘¬ν”„νŠΈ 톡계 API κ΅¬ν˜„ (#456)
minij02 May 16, 2026
816f453
Merge pull request #465 from PromptPlace/feat/#456
minij02 May 16, 2026
028bb99
feat: ν™œμ„± μ‚¬μš©μž 좔적 인프라 및 톡계 API κ΅¬ν˜„ (#462)
minij02 May 16, 2026
6ce34ed
Merge pull request #466 from PromptPlace/feat/#462
minij02 May 16, 2026
38130a0
feat: 방문자 좔적 인프라 및 톡계 API κ΅¬ν˜„ (#461)
minij02 May 16, 2026
dcfebc8
Merge pull request #467 from PromptPlace/feat/#461
minij02 May 16, 2026
0af7fdb
feat: ν”„λ‘¬ν”„νŠΈ 일일 μŠ€λƒ…μƒ· 인프라 및 인기 ν”„λ‘¬ν”„νŠΈ API κ΅¬ν˜„ (#464)
minij02 May 16, 2026
8092a46
Merge pull request #468 from PromptPlace/feat/#464
minij02 May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ node_modules
.env.dev

/generated/prisma
dist/
dist/

# Local tooling state
.omc/
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.10",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
Expand Down Expand Up @@ -67,6 +68,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.0.0",
"@types/node": "^24.0.10",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.3",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
Expand Down
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `User` ADD COLUMN `last_active_at` DATETIME(3) NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE `PromptStatDaily` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`prompt_id` INTEGER NOT NULL,
`snapshot_date` VARCHAR(10) NOT NULL,
`views` INTEGER NOT NULL DEFAULT 0,
`downloads` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),

INDEX `PromptStatDaily_snapshot_date_idx`(`snapshot_date`),
UNIQUE INDEX `PromptStatDaily_prompt_id_snapshot_date_key`(`prompt_id`, `snapshot_date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ model User {
status Boolean
userstatus userStatus @default(active)
inactive_date DateTime?
last_active_at DateTime?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
role Role @default(USER)
Expand Down Expand Up @@ -283,6 +284,18 @@ model PromptModel {
@@index([prompt_id], map: "PromptModel_prompt_id_fkey")
}

model PromptStatDaily {
id Int @id @default(autoincrement())
prompt_id Int
snapshot_date String @db.VarChar(10)
views Int @default(0)
downloads Int @default(0)
created_at DateTime @default(now())

@@unique([prompt_id, snapshot_date])
@@index([snapshot_date])
}

model Model {
model_id Int @id @default(autoincrement())
name String @db.VarChar(50)
Expand Down
3 changes: 3 additions & 0 deletions src/config/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { configureGoogleStrategy } from "./social/google";
import { configureNaverStrategy } from "./social/naver";
import { configureKakaoStrategy } from "./social/kakao";
import { isActive } from "../utils/status";
import { recordUserActivity } from "../utils/user-activity";
import prisma from "./prisma";

// JWT Strategy μ„€μ •
Expand Down Expand Up @@ -40,6 +41,8 @@ passport.use(
return done(error, false);
}

void recordUserActivity(user.user_id);

return done(null, user);
} catch (err) {
return done(err as Error, false);
Expand Down
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import http from "http";
import { Server } from "socket.io"
import { responseHandler } from "./middlewares/responseHandler";
import { errorHandler } from "./middlewares/errorHandler";
import { visitorTracker } from "./middlewares/visitorTracker";
import "reflect-metadata";
import passport from "./config/passport";
import swaggerUi from "swagger-ui-express";
Expand All @@ -29,11 +30,14 @@ import "./notifications/listeners/notification.listener"; // μ•Œλ¦Ό λ¦¬μŠ€ν„° im
import messageRouter from "./messages/routes/message.route";
import adminPromptRouter from "./prompts/routes/admin-prompt.route";
import adminMemberRouter from "./members/routes/admin-member.route";
import adminSellerRouter from "./settlements/routes/admin-seller.route";
import adminStatsRouter from "./stats/routes/admin-stats.route";
import signupRouter from "./signup/routes/signup.route"
import signinRouter from "./signin/routes/signin.route";
import passwordRouter from "./password/routes/password.route";
import chatRouter from "./chat/routes/chat.route";
import { initSocket } from "./socket/server";
import { startPromptStatSnapshotJob } from "./stats/jobs/prompt-stat-snapshot.job";
import morgan = require('morgan');
const PORT = 3000;
const app = express();
Expand All @@ -42,6 +46,7 @@ const app = express();
const server = http.createServer(app);

initSocket(server)
startPromptStatSnapshotJob();
// 1. 응닡 ν•Έλ“€λŸ¬(json νŒŒμ„œλ³΄λ‹€ μœ„μ—)
app.use(responseHandler);
app.use((req, res, next) => {
Expand Down Expand Up @@ -100,6 +105,9 @@ app.use(
swaggerUi.setup(swaggerJsdoc(swaggerOptions))
);

// 방문자 좔적 미듀웨어 (응닡 μ’…λ£Œ μ‹œ HyperLogLog에 visitor_id λˆ„μ )
app.use(visitorTracker);

// 3. λͺ¨λ“  λΌμš°ν„°λ“€

// 둜그인 λΌμš°ν„°
Expand Down Expand Up @@ -153,6 +161,8 @@ app.use("/api/prompts", promptLikeRouter);

// admin
app.use("/api/admin/prompts", adminPromptRouter);
app.use("/api/admin/sellers", adminSellerRouter);
app.use("/api/admin/stats", adminStatsRouter);
app.use("/api/admin", adminMemberRouter);

// 팁 λΌμš°ν„°
Expand Down
34 changes: 34 additions & 0 deletions src/middlewares/visitorTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Request, Response, NextFunction } from 'express';
import {
computeVisitorId,
isBotUserAgent,
recordVisit,
} from '../utils/visitor-tracking';

const SKIP_PATH_PREFIXES = [
'/health',
'/api-docs',
'/uploads',
'/api/notifications/sse',
];

const SKIP_METHODS = new Set(['OPTIONS', 'HEAD']);

export const visitorTracker = (
req: Request,
res: Response,
next: NextFunction,
): void => {
if (SKIP_METHODS.has(req.method)) return next();
if (SKIP_PATH_PREFIXES.some((prefix) => req.path.startsWith(prefix))) {
return next();
}
if (isBotUserAgent(req.headers['user-agent'])) return next();

res.on('finish', () => {
if (res.statusCode >= 400) return;
void recordVisit(computeVisitorId(req));
});

next();
};
163 changes: 163 additions & 0 deletions src/settlements/controllers/admin-seller.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../../errors/AppError';
import {
approvePendingBusinessSeller,
cancelSeller,
getBusinessSellerDetail,
getIndividualSellerDetail,
getPendingBusinessSellerDetail,
listBusinessSellers,
listIndividualSellers,
listPendingBusinessSellers,
rejectPendingBusinessSeller,
} from '../services/admin-seller.service';
import {
ListPendingQueryDto,
ListSellersQueryDto,
} from '../dtos/admin-seller.dto';

const parseUserIdParam = (raw: string): number => {
const userId = Number(raw);
if (!Number.isInteger(userId) || userId <= 0) {
throw new AppError(
'μœ νš¨ν•˜μ§€ μ•Šμ€ μ‚¬μš©μž IDμž…λ‹ˆλ‹€.',
400,
'ValidationError',
);
}
return userId;
};

export const getPendingSellerList = async (
req: Request<unknown, unknown, unknown, ListPendingQueryDto>,
res: Response,
next: NextFunction,
) => {
try {
const result = await listPendingBusinessSellers(
req.query.page,
req.query.limit,
);
return res.success(result, '승인 λŒ€κΈ° μ‚¬μ—…μž 판맀자 λͺ©λ‘μ„ μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const getPendingSellerDetail = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
const result = await getPendingBusinessSellerDetail(userId);
return res.success(result, '승인 λŒ€κΈ° μ‚¬μ—…μž 판맀자 상세 정보λ₯Ό μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const approveSeller = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
await approvePendingBusinessSeller(userId);
return res.success({ user_id: userId }, 'μ‚¬μ—…μž 판맀자 등둝을 μŠΉμΈν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const rejectSeller = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
await rejectPendingBusinessSeller(userId);
return res.success({ user_id: userId }, 'μ‚¬μ—…μž 판맀자 등둝을 λ°˜λ €ν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const getIndividualSellerList = async (
req: Request<unknown, unknown, unknown, ListSellersQueryDto>,
res: Response,
next: NextFunction,
) => {
try {
const result = await listIndividualSellers(
req.query.page,
req.query.limit,
req.query.search,
);
return res.success(result, '개인 판맀자 λͺ©λ‘μ„ μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const getBusinessSellerList = async (
req: Request<unknown, unknown, unknown, ListSellersQueryDto>,
res: Response,
next: NextFunction,
) => {
try {
const result = await listBusinessSellers(
req.query.page,
req.query.limit,
req.query.search,
);
return res.success(result, 'μ‚¬μ—…μž 판맀자 λͺ©λ‘μ„ μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const getIndividualSellerDetailHandler = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
const result = await getIndividualSellerDetail(userId);
return res.success(result, '개인 판맀자 상세 정보λ₯Ό μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const getBusinessSellerDetailHandler = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
const result = await getBusinessSellerDetail(userId);
return res.success(result, 'μ‚¬μ—…μž 판맀자 상세 정보λ₯Ό μ‘°νšŒν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};

export const cancelSellerHandler = async (
req: Request<{ userId: string }>,
res: Response,
next: NextFunction,
) => {
try {
const userId = parseUserIdParam(req.params.userId);
const result = await cancelSeller(userId);
return res.success(result, '판맀자 등둝을 μ·¨μ†Œν–ˆμŠ΅λ‹ˆλ‹€.');
} catch (error) {
return next(error);
}
};
Loading
Loading