Function bodies 248 total
main function · typescript · L5-L37 (33 LOC)prisma/seed.ts
async function main() {
// 테스트 유저 생성
const user = await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: {
email: "[email protected]",
name: "테스트 유저",
},
});
console.log("User:", user.id, user.email);
// 테스트 브랜드 생성
const brand = await prisma.brand.upsert({
where: { id: "test-brand-001" },
update: {},
create: {
id: "test-brand-001",
userId: user.id,
name: "글로우랩 뷰티",
niche: "cosmetics",
igHandle: "glowlab_beauty",
tiktokHandle: "glowlab_beauty",
tone: "친근하고 전문적인",
description: "피부과학 기반 클린뷰티 브랜드. 20~30대 여성 타겟.",
},
});
console.log("Brand:", brand.id, brand.name);
console.log("\nSeed complete!");
console.log("Test user ID:", user.id);
console.log("Test brand ID:", brand.id);
}parseCsvLine function · typescript · L89-L117 (29 LOC)scripts/eval-auto-score.ts
function parseCsvLine(line: string): string[] {
const cols: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
cols.push(current);
current = "";
} else {
current += ch;
}
}
}
cols.push(current);
return cols;
}scoreOne function · typescript · L119-L129 (11 LOC)scripts/eval-auto-score.ts
async function scoreOne(
niche: string,
copyText: string
): Promise<{
hookStrength: number;
structure: number;
brandFit: number;
ctaClarity: number;
complianceSafety: number;
reasoning: string;
}> {main function · typescript · L169-L274 (106 LOC)scripts/eval-auto-score.ts
async function main() {
const blindPath = path.join(__dirname, "..", "data", "eval-blind.csv");
if (!fs.existsSync(blindPath)) {
console.error("eval-blind.csv not found. Run: npx tsx scripts/eval-quality.ts export");
process.exit(1);
}
const raw = fs.readFileSync(blindPath, "utf-8").trim().split("\n");
const header = raw[0];
const rows = raw.slice(1).map((line) => {
const cols = parseCsvLine(line);
return {
contentId: cols[0]?.trim(),
group: cols[1]?.trim(),
niche: cols[2]?.trim(),
copyText: cols[3]?.trim(),
};
}).filter((r) => r.contentId && r.group);
console.log(`Scoring ${rows.length} items with Claude Haiku...\n`);
const scored: EvalRow[] = [];
let totalCost = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
process.stdout.write(` [${i + 1}/${rows.length}] ${row.contentId} (${row.group}, ${row.niche})... `);
const score = await scoreOne(row.niche, row.copyText);
const total = scoloadNiche function · typescript · L25-L29 (5 LOC)scripts/eval-generate.ts
function loadNiche(nicheId: string) {
const filePath = path.join(__dirname, "..", "niche-templates", `${nicheId}.json`);
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}buildSystemPrompt function · typescript · L32-L75 (44 LOC)scripts/eval-generate.ts
function buildSystemPrompt(niche: any, recentHookTypes: string[]): string {
const { prompt_builder } = require("../src/lib/prompt-builder");
// fallback: 직접 구성
const hookTypes = ["question", "shock", "empathy", "secret", "reversal", "list"];
const formats = ["info", "empathy", "behind", "review", "event"];
const avoidBlock = recentHookTypes.length > 0
? `\n<diversity>\n최근 사용된 훅: [${recentHookTypes.join(", ")}]. 이 훅은 피하고 다른 훅을 선택하세요.\n</diversity>`
: "";
return `당신은 한국 숏폼(인스타 릴스/유튜브 쇼츠) 성장 전문 카피라이터입니다.
<viral_framework>
6가지 훅 타입 중 하나를 선택하세요:
- question: 궁금증 유발 질문 ("이것도 모르고 바르셨어요?")
- shock: 충격/반전 사실 ("화장품 회사가 절대 안 알려주는 것")
- empathy: 공감 ("매일 아침 피부 때문에 우울했던 당신에게")
- secret: 비밀/인사이더 ("업계 10년차가 알려주는 꿀팁")
- reversal: 반전/파괴 ("비싼 게 다 좋은 줄 알았는데...")
- list: 숫자/리스트 ("피부 좋아지는 5가지 습관")
</viral_framework>
<content_formats>
5가지 콘텐츠 포맷:
- info: 정보 전달형 (팁, 노하우)
- empathy: 공감 스토리형 (경험, 감정)
- behind: 비하인드/과정 공개형
- review: 리뷰/비교형
- event: 이벤트/프로모션형
</content_formats>
${avoidBlockgenerate function · typescript · L99-L130 (32 LOC)scripts/eval-generate.ts
async function generate(brand: any, niche: any, recentHookTypes: string[], topic?: string) {
const systemPrompt = buildSystemPrompt(niche, recentHookTypes);
const userPrompt = `브랜드: ${brand.name}
톤: ${brand.tone}
${brand.description ? `설명: ${brand.description}` : ""}
${topic ? `주제: ${topic}` : "자유 주제로 생성해주세요."}
먼저 hookType과 contentFormat을 결정한 후, 카피를 작성하세요.`;
const response = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 900,
temperature: 0.8,
tools: [generateCopyTool],
tool_choice: { type: "tool", name: "generate_copy" },
system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: userPrompt }],
});
const toolBlock = response.content.find((b) => b.type === "tool_use");
if (!toolBlock || toolBlock.type !== "tool_use") return null;
const result = toolBlock.input as {
hookType: string; contentFormat: string;
copyText: string; hashtags: string; vidRepobility (the analyzer behind this table) · https://repobility.com
main function · typescript · L132-L199 (68 LOC)scripts/eval-generate.ts
async function main() {
const countArg = Number(process.argv[2]) || 5;
const brandIdArg = process.argv[3];
const brands = await prisma.brand.findMany({
where: brandIdArg ? { id: brandIdArg } : undefined,
});
if (brands.length === 0) {
console.log("No brands found.");
await prisma.$disconnect();
return;
}
console.log(`Generating ${countArg} items per brand (${brands.length} brands)...`);
let totalGenerated = 0;
let totalTokens = 0;
for (const brand of brands) {
const niche = loadNiche(brand.niche);
if (!niche) {
console.log(` Skipping ${brand.name}: niche '${brand.niche}' not found`);
continue;
}
// 다양성 제어: 최근 hookType 누적
const recentHookTypes: string[] = [];
console.log(`\n Brand: ${brand.name} (${brand.niche})`);
for (let i = 0; i < countArg; i++) {
process.stdout.write(` [${i + 1}/${countArg}] generating...`);
const result = await generate(brand, niche, recentHookTypes);
if (!reexportForEval function · typescript · L39-L75 (37 LOC)scripts/eval-quality.ts
async function exportForEval() {
// 최근 콘텐츠를 가져와서 metadata 유무로 old/new 구분
const contents = await prisma.content.findMany({
orderBy: { createdAt: "desc" },
take: 100,
include: { brand: { select: { niche: true, name: true } } },
});
const rows: EvalRow[] = contents.map((c) => ({
contentId: c.id,
group: c.metadata ? "new" : "old",
niche: c.brand.niche,
copyText: c.copyText,
}));
// 셔플 (블라인드)
for (let i = rows.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[rows[i], rows[j]] = [rows[j], rows[i]];
}
// CSV 이스케이프: 줄바꿈을 \n 리터럴로, 큰따옴표는 "" 이스케이프
const csvEscape = (s: string) =>
`"${s.replace(/"/g, '""').replace(/\r?\n/g, "\\n")}"`;
const header = "contentId,group,niche,copyText,hookStrength,structure,brandFit,ctaClarity,complianceSafety";
const csv = [header, ...rows.map((r) =>
`${r.contentId},${r.group},${r.niche},${csvEscape(r.copyText)},,,,,`
)].join("\n");
const outPath = path.join(__dirnamanalyzeResults function · typescript · L77-L177 (101 LOC)scripts/eval-quality.ts
function analyzeResults() {
const csvPath = path.join(__dirname, "..", "data", "eval-scored.csv");
if (!fs.existsSync(csvPath)) {
console.log("No eval-scored.csv found. Run evaluation first.");
console.log("1. Export: npx tsx scripts/eval-quality.ts export");
console.log("2. Score each row in data/eval-blind.csv (1~5 per column)");
console.log("3. Save as data/eval-scored.csv");
console.log("4. Analyze: npx tsx scripts/eval-quality.ts analyze");
return;
}
// RFC 4180 CSV 파서: 큰따옴표 필드 지원
function parseCsvLine(line: string): string[] {
const cols: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
iparseCsvLine function · typescript · L89-L117 (29 LOC)scripts/eval-quality.ts
function parseCsvLine(line: string): string[] {
const cols: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
cols.push(current);
current = "";
} else {
current += ch;
}
}
}
cols.push(current);
return cols;
}main function · typescript · L179-L192 (14 LOC)scripts/eval-quality.ts
async function main() {
const command = process.argv[2] || "export";
try {
if (command === "export") {
await exportForEval();
} else if (command === "analyze") {
analyzeResults();
} else {
console.log("Usage: npx tsx scripts/eval-quality.ts [export|analyze]");
}
} finally {
await prisma.$disconnect();
}
}GET function · typescript · L8-L62 (55 LOC)src/app/api/analytics/patterns/killed/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "analytics-killed", 20, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다." },
{ status: 429 }
);
}
const { searchParams } = new URL(req.url);
const brandId = searchParams.get("brandId");
if (!brandId) {
return NextResponse.json(
{ success: false, error: "brandId가 필요합니다" },
{ status: 400 }
);
}
// Verify ownership
const brand = await prisma.brand.findFirst({
where: { id: brandId, userId: session.user.id },
select: { id: true },
});
if (!brand) {
return NextResponse.json(
{ success: false, error: "브랜드를 찾을 수 없습니다" },
GET function · typescript · L8-L62 (55 LOC)src/app/api/analytics/patterns/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "analytics-patterns", 20, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다." },
{ status: 429 }
);
}
const { searchParams } = new URL(req.url);
const brandId = searchParams.get("brandId");
if (!brandId) {
return NextResponse.json(
{ success: false, error: "brandId가 필요합니다" },
{ status: 400 }
);
}
// Verify ownership
const brand = await prisma.brand.findFirst({
where: { id: brandId, userId: session.user.id },
select: { id: true },
});
if (!brand) {
return NextResponse.json(
{ success: false, error: "브랜드를 찾을 수 없습니다" },
POST function · typescript · L33-L118 (86 LOC)src/app/api/analytics/route.ts
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rateLimit = checkRateLimit(session.user.id, "analytics", 30, 60_000);
if (!rateLimit.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다." },
{ status: 429 }
);
}
const body = await req.json();
if (body === null || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json(
{ success: false, error: "요청 본문은 JSON 객체여야 합니다" },
{ status: 400 }
);
}
// Support both single and batch upsert
const isBatch = "items" in body;
const items = isBatch
? batchUpsertSchema.parse(body).items
: [upsertSchema.parse(body)];
const results: { contentId: string; updated: boolean }[] = [];
fOpen data scored by Repobility · https://repobility.com
GET function · typescript · L122-L263 (142 LOC)src/app/api/analytics/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rlGet = checkRateLimit(session.user.id, "analytics-get", 30, 60_000);
if (!rlGet.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다." },
{ status: 429 }
);
}
const { searchParams } = new URL(req.url);
const brandId = searchParams.get("brandId");
const from = searchParams.get("from");
const to = searchParams.get("to");
const limit = Math.min(Number(searchParams.get("limit")) || 50, 100);
const sortBy = searchParams.get("sortBy") || "createdAt";
const order = searchParams.get("order") === "asc" ? "asc" : "desc";
// Build where clause
const where: Record<string, unknown> = {
brand: { userId: session.user.id },
// GET function · typescript · L7-L49 (43 LOC)src/app/api/brands/[id]/route.ts
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "brand-get", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다." },
{ status: 429 }
);
}
const { id } = await params;
const brand = await prisma.brand.findFirst({
where: { id, userId: session.user.id },
});
if (!brand) {
return NextResponse.json(
{ success: false, error: "브랜드를 찾을 수 없습니다" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: brand });
} catch (error) {
console.error("브랜드 조회 실패:", error);
return NextResponse.json(
{ success: false, error:GET function · typescript · L34-L66 (33 LOC)src/app/api/brands/route.ts
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "brands-list", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const brands = await prisma.brand.findMany({
where: { userId: session.user.id, isActive: true },
include: { _count: { select: { contents: true } } },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ success: true, data: brands });
} catch (error) {
console.error("브랜드 목록 조회 실패:", error);
return NextResponse.json(
{ success: false, error: "브랜드 목록을 불러올 수 없습니다" },
{ status: 500 }
);
}
}POST function · typescript · L69-L160 (92 LOC)src/app/api/brands/route.ts
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "brands-create", 10, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = brandCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
// 플랜별 브랜드 수 제한 체크
const [existingCount, user] = await Promise.all([
prisma.brand.count({
where: { userId: session.user.id, isActive: true },
}),
prisma.userPUT function · typescript · L163-L230 (68 LOC)src/app/api/brands/route.ts
export async function PUT(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "brands-update", 10, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = brandUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
const { id, ...fields } = parsed.data;
// 소유권 확인
const existing = await prisma.brand.findFirst({
where: { id, userId: session.user.id, isActive: true },
});
if (!exDELETE function · typescript · L233-L291 (59 LOC)src/app/api/brands/route.ts
export async function DELETE(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "brands-delete", 5, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = brandDeleteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
const { id } = parsed.data;
// 소유권 확인
const existing = await prisma.brand.findFirst({
where: { id, userId: session.user.id, isActive: true },
});
if (!existing) {GET function · typescript · L8-L43 (36 LOC)src/app/api/buffer/route.ts
export async function GET() {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "buffer-profiles", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
if (!process.env.BUFFER_ACCESS_TOKEN) {
return NextResponse.json({
success: true,
data: [],
message: "Buffer 연동이 설정되지 않았습니다",
});
}
const profiles = await getBufferProfiles();
return NextResponse.json({ success: true, data: profiles });
} catch (error) {
console.error("Buffer 프로필 조회 실패:", error);
return NextResponse.json(
{ success: false, error: "Buffer 프로필을 불러올 수 없습니다" },
{ status: 500 }
);
}
}GET function · typescript · L9-L110 (102 LOC)src/app/api/calendar/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "calendar", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const calendarParamsSchema = z.object({
year: z.coerce.number().int().min(2000).max(2100).optional(),
month: z.coerce.number().int().min(1).max(12).optional(),
});
const { searchParams } = new URL(req.url);
const paramsParsed = calendarParamsSchema.safeParse({
year: searchParams.get("year") ?? undefined,
month: searchParams.get("month") ?? undefined,
});
if (!paramsParsed.success) {
return NextResponse.json(
{
success: false,
Citation: Repobility (2026). State of AI-Generated Code. https://repobility.com/research/
isValidImage function · typescript · L18-L29 (12 LOC)src/app/api/card-news/upload/route.ts
function isValidImage(buffer: Buffer): boolean {
if (buffer.length < 12) return false;
return IMAGE_SIGNATURES.some((sig) => {
const headerMatch = sig.bytes.every((b, i) => buffer[i] === b);
if (!headerMatch) return false;
// WebP: also check "WEBP" at offset 8
if (sig.mime === "image/webp") {
return buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50;
}
return true;
});
}POST function · typescript · L32-L142 (111 LOC)src/app/api/card-news/upload/route.ts
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// Rate limit: 분당 10회
const rl = checkRateLimit(session.user.id, "card-news-upload", 10, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "업로드 요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const formData = await req.formData();
const contentId = formData.get("contentId") as string;
if (!contentId || typeof contentId !== "string") {
return NextResponse.json(
{ success: false, error: "contentId가 필요합니다" },
{ status: 400 }
);
}
// 소유권 + 타입 확인
const content = await prisma.content.findFirst({
where: { id: contentId, brand: { userId: session.user.id } },
});
if (!content) {
return getOwnedCharacter function · typescript · L29-L37 (9 LOC)src/app/api/characters/[id]/route.ts
async function getOwnedCharacter(id: string, userId: string) {
return prisma.character.findFirst({
where: {
id,
brand: { userId },
},
include: { brand: { select: { id: true, name: true, userId: true } } },
});
}GET function · typescript · L40-L78 (39 LOC)src/app/api/characters/[id]/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "characters-get", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const character = await getOwnedCharacter(params.id, session.user.id);
if (!character) {
return NextResponse.json(
{ success: false, error: "캐릭터를 찾을 수 없습니다" },
{ status: 404 }
);
}
return NextResponse.json({ success: true, data: character });
} catch (error) {
console.error("캐릭터 조회 실패:", error);
return NextResponse.json(
{ success: false, error: "캐릭터를 불러올 수 없습니다" },
{ status: 500 }PATCH function · typescript · L81-L156 (76 LOC)src/app/api/characters/[id]/route.ts
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "characters-update", 10, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = characterUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
// 소유권 확인
const existing = await getOwnedCharacter(params.id, session.user.id);
if (!existing) {
return NextResponse.jDELETE function · typescript · L159-L202 (44 LOC)src/app/api/characters/[id]/route.ts
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "characters-delete", 5, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
// 소유권 확인
const existing = await getOwnedCharacter(params.id, session.user.id);
if (!existing) {
return NextResponse.json(
{ success: false, error: "캐릭터를 찾을 수 없습니다" },
{ status: 404 }
);
}
await prisma.character.update({
where: { id: params.id },
data: { isActive: false },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("캐릭터 삭제 실패:", erGET function · typescript · L29-L84 (56 LOC)src/app/api/characters/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "characters-list", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const { searchParams } = new URL(req.url);
const brandId = searchParams.get("brandId");
// 유저 소유 브랜드 ID 목록 조회 (소유권 보장)
const userBrands = await prisma.brand.findMany({
where: { userId: session.user.id, isActive: true },
select: { id: true },
});
const userBrandIds = userBrands.map((b) => b.id);
const whereClause = {
brandId: brandId
? // 특정 브랜드 필터 + 소유권 확인
userBrandIds.includes(brandId)
? brandId
: "__none__POST function · typescript · L87-L158 (72 LOC)src/app/api/characters/route.ts
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "characters-create", 10, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = characterCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
// 브랜드 소유권 확인
const brand = await prisma.brand.findFirst({
where: { id: parsed.data.brandId, userId: session.user.id, isActive: true },
});
if (!brand) {
Want this analysis on your repo? https://repobility.com/scan/
POST function · typescript · L25-L255 (231 LOC)src/app/api/image/generate/route.ts
export async function POST(req: NextRequest) {
const idempotencyKey = getIdempotencyKey(req);
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 멱등성 체크
const idem = await checkIdempotency(
idempotencyKey,
"image-generate",
session.user.id
);
if (idem.isDuplicate) {
if (idem.cachedResponse) return idem.cachedResponse;
return NextResponse.json(
{ success: false, error: "이전 요청이 처리 중입니다" },
{ status: 409 }
);
}
if (!isImageEnabled()) {
await failIdempotency(idempotencyKey);
return NextResponse.json(
{
success: false,
error: "이미지 생성이 설정되지 않았습니다 (FAL_KEY 필요)",
},
{ status: 503 }
);
}
// Rate limit: 10 requests per 60 seconds
const rateLimit = checkRateLimit(
session.usGET function · typescript · L14-L290 (277 LOC)src/app/api/image/status/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// Rate limit: 30 polls per minute
const rateLimit = checkRateLimit(
session.user.id,
"image-status",
30,
60_000
);
if (!rateLimit.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const contentIdSchema = z.string().min(1, "contentId가 필요합니다");
const contentIdParam = req.nextUrl.searchParams.get("contentId");
const contentIdParsed = contentIdSchema.safeParse(contentIdParam);
if (!contentIdParsed.success) {
return NextResponse.json(
{
success: false,
error: contentIdParsed.error.issues
.map((i) => i.message)
.join(", "),POST function · typescript · L22-L202 (181 LOC)src/app/api/image/upscale/route.ts
export async function POST(req: NextRequest) {
const idempotencyKey = getIdempotencyKey(req);
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 멱등성 체크
const idem = await checkIdempotency(
idempotencyKey,
"image-upscale",
session.user.id
);
if (idem.isDuplicate) {
if (idem.cachedResponse) return idem.cachedResponse;
return NextResponse.json(
{ success: false, error: "이전 요청이 처리 중입니다" },
{ status: 409 }
);
}
if (!isImageEnabled()) {
await failIdempotency(idempotencyKey);
return NextResponse.json(
{
success: false,
error: "이미지 업스케일이 설정되지 않았습니다 (FAL_KEY 필요)",
},
{ status: 503 }
);
}
// Rate limit: 5 requests per 60 seconds (업스케일은 비용 더 높음)
const rateLimit = checkRateLimit(
GET function · typescript · L5-L29 (25 LOC)src/app/api/niches/route.ts
export async function GET(req: NextRequest) {
// IP-based rate limit for this intentionally public endpoint
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
req.headers.get("x-real-ip") ??
"unknown";
const rl = checkRateLimit(`ip:${ip}`, "niches", 60, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
try {
const niches = getNicheList();
return NextResponse.json({ success: true, data: niches });
} catch (error) {
console.error("니치 목록 조회 실패:", error);
return NextResponse.json(
{ success: false, error: "니치 목록을 불러올 수 없습니다" },
{ status: 500 }
);
}
}loadInstagramModule function · typescript · L33-L41 (9 LOC)src/app/api/publish/route.ts
async function loadInstagramModule(): Promise<InstagramModule | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = (await import("@/lib/instagram")) as any;
return mod as InstagramModule;
} catch {
return null;
}
}cleanupZombieLocks function · typescript · L44-L62 (19 LOC)src/app/api/publish/route.ts
async function cleanupZombieLocks(): Promise<void> {
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
await prisma.content.updateMany({
where: {
status: "APPROVED",
bufferPostId: { startsWith: "pending_" },
updatedAt: { lt: tenMinutesAgo },
},
data: { bufferPostId: null, igMediaId: null },
});
await prisma.content.updateMany({
where: {
status: "APPROVED",
igMediaId: { startsWith: "pending_" },
updatedAt: { lt: tenMinutesAgo },
},
data: { igMediaId: null },
});
}POST function · typescript · L65-L459 (395 LOC)src/app/api/publish/route.ts
export async function POST(req: NextRequest) {
const idempotencyKey = getIdempotencyKey(req);
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 멱등성 체크
const idem = await checkIdempotency(idempotencyKey, "publish", session.user.id);
if (idem.isDuplicate) {
if (idem.cachedResponse) return idem.cachedResponse;
return NextResponse.json(
{ success: false, error: "이전 요청이 처리 중입니다" },
{ status: 409 }
);
}
const rl = checkRateLimit(session.user.id, "publish", 5, 60_000);
if (!rl.allowed) {
await failIdempotency(idempotencyKey);
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = publishSchema.safeParse(body);
if (!parseGET function · typescript · L11-L62 (52 LOC)src/app/api/queue/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "queue-list", 30, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const url = new URL(req.url);
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 50, 1), 100);
const offset = Math.max(Number(url.searchParams.get("offset")) || 0, 0);
const [contents, total] = await Promise.all([
prisma.content.findMany({
where: {
brand: { userId: session.user.id },
status: { in: ["PENDING", "DRAFT", "APPROVED"] },
},
include: {
brand: { select: { id: true, name: true, niche: true }Repobility (the analyzer behind this table) · https://repobility.com
PATCH function · typescript · L76-L262 (187 LOC)src/app/api/queue/route.ts
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const rl = checkRateLimit(session.user.id, "queue-action", 20, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const body = await req.json();
const parsed = queueActionSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: parsed.error.issues.map((i) => i.message).join(", "),
},
{ status: 400 }
);
}
// 콘텐츠 소유권 확인
const content = await prisma.content.findFirst({
where: {
id: parsed.data.contentId,
brand: { userId: session.user.id },
},
});
if (!GET function · typescript · L13-L60 (48 LOC)src/app/api/uploads/[...path]/route.ts
export async function GET(
_req: NextRequest,
{ params }: { params: { path: string[] } }
) {
const segments = params.path;
// Path traversal 방지
if (segments.some((s) => s.includes("..") || s.includes("~"))) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const filePath = path.join(process.cwd(), "uploads", ...segments);
const resolved = path.resolve(filePath);
const uploadRoot = path.resolve(path.join(process.cwd(), "uploads"));
if (!resolved.startsWith(uploadRoot)) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
try {
const ext = path.extname(resolved).toLowerCase();
const ALLOWED_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".mp4": "video/mp4",
};
const contentType = ALLOWED_MIME[ext];
if (!contentType) {
return GET function · typescript · L24-L68 (45 LOC)src/app/api/usage/route.ts
export async function GET() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 이번 달 시작일 (UTC)
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const monthLabel = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
// 서비스별 GROUP BY 집계 — Prisma groupBy 사용
const groups = await prisma.usageRecord.groupBy({
by: ["service"],
where: {
userId: session.user.id,
createdAt: { gte: monthStart },
},
_sum: { costUsd: true, units: true },
_count: { id: true },
});
const breakdown: UsageSummaryItem[] = groups.map((g) => ({
service: g.service,
totalUnits: Number(g._sum.units ?? 0),
totalCostUsd: Number(g._sum.costUsd ?? 0),
recordCount: g._count.id,
}));
const totalCostUsd = breakdown.reduce((acc, b) => acPOST function · typescript · L24-L210 (187 LOC)src/app/api/video/generate/route.ts
export async function POST(req: NextRequest) {
const idempotencyKey = getIdempotencyKey(req);
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 멱등성 체크
const idem = await checkIdempotency(idempotencyKey, "video-generate", session.user.id);
if (idem.isDuplicate) {
if (idem.cachedResponse) return idem.cachedResponse;
return NextResponse.json(
{ success: false, error: "이전 요청이 처리 중입니다" },
{ status: 409 }
);
}
if (!isVideoEnabled()) {
await failIdempotency(idempotencyKey);
return NextResponse.json(
{ success: false, error: "영상 생성이 설정되지 않았습니다 (FAL_KEY 필요)" },
{ status: 503 }
);
}
// Rate limit: 5 requests per 60 seconds
const rateLimit = checkRateLimit(session.user.id, "video-generate", 5, 60_000);
if (!rateLimit.alloweGET function · typescript · L13-L242 (230 LOC)src/app/api/video/status/route.ts
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// Rate limit: 30 polls per minute (min 2s client-side + 10s server-side guard is
// already enforced by videoPolledAt, but this caps raw HTTP flood)
const rateLimit = checkRateLimit(session.user.id, "video-status", 30, 60_000);
if (!rateLimit.allowed) {
return NextResponse.json(
{ success: false, error: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요." },
{ status: 429 }
);
}
const contentIdSchema = z.string().min(1, "contentId가 필요합니다");
const contentIdParam = req.nextUrl.searchParams.get("contentId");
const contentIdParsed = contentIdSchema.safeParse(contentIdParam);
if (!contentIdParsed.success) {
return NextResponse.json(
{ success: false, error: contentIdParsed.errPOST function · typescript · L29-L235 (207 LOC)src/app/api/voice/generate/route.ts
export async function POST(req: NextRequest) {
const idempotencyKey = getIdempotencyKey(req);
try {
// 인증 확인
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 멱등성 체크
const idem = await checkIdempotency(
idempotencyKey,
"voice-generate",
session.user.id
);
if (idem.isDuplicate) {
if (idem.cachedResponse) return idem.cachedResponse;
return NextResponse.json(
{ success: false, error: "이전 요청이 처리 중입니다" },
{ status: 409 }
);
}
// 음성 기능 활성화 여부 확인
if (!isVoiceEnabled()) {
await failIdempotency(idempotencyKey);
return NextResponse.json(
{
success: false,
error: "음성 생성이 설정되지 않았습니다 (ELEVENLABS_API_KEY 필요)",
},
{ status: 503 }
);
}
// Rate limit: 10 requests per 60 seconds (TTS는 비교GET function · typescript · L34-L94 (61 LOC)src/app/api/voice/list/route.ts
export async function GET() {
try {
// 인증 확인
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 음성 기능 비활성화 시 — graceful empty response
if (!isVoiceEnabled()) {
return NextResponse.json({
success: true,
data: {
voices: [],
disabled: true,
message: "음성 기능이 비활성화되어 있습니다 (ELEVENLABS_API_KEY 미설정)",
},
});
}
// 캐시 히트 확인
const now = Date.now();
if (cache && now - cache.cachedAt < CACHE_TTL_MS) {
return NextResponse.json({
success: true,
data: {
voices: sortVoices(cache.voices),
cached: true,
cachedAt: new Date(cache.cachedAt).toISOString(),
},
});
}
// ElevenLabs API 호출
const provider = getVoiceProvider();
const voices = await provider.listVoices();
// 캐시sortVoices function · typescript · L100-L107 (8 LOC)src/app/api/voice/list/route.ts
function sortVoices(voices: VoiceInfo[]): VoiceInfo[] {
return [...voices].sort((a, b) => {
const aKorean = KOREAN_PRIORITY_IDS.has(a.id) ? 0 : 1;
const bKorean = KOREAN_PRIORITY_IDS.has(b.id) ? 0 : 1;
if (aKorean !== bKorean) return aKorean - bKorean;
return a.name.localeCompare(b.name);
});
}Open data scored by Repobility · https://repobility.com
AnalyticsPage function · typescript · L39-L270 (232 LOC)src/app/dashboard/analytics/page.tsx
export default function AnalyticsPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [brandsLoading, setBrandsLoading] = useState(true);
const [brandsError, setBrandsError] = useState<string | null>(null);
const [selectedBrandId, setSelectedBrandId] = useState<string>("");
const [contents, setContents] = useState<ContentItem[]>([]);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [insight, setInsight] = useState<PerformanceInsight | null>(null);
const [killedPatterns, setKilledPatterns] = useState<KilledPattern[]>([]);
const [loading, setLoading] = useState(false);
const [sortBy, setSortBy] = useState<string>("publishedAt");
// Fetch brands on mount
useEffect(() => {
fetch("/api/brands")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((res) => {
if (res.success && res.data?.length > 0) {
setBrands(res.data);
setSeSummaryCard function · typescript · L274-L282 (9 LOC)src/app/dashboard/analytics/page.tsx
function SummaryCard({ icon, label, value }: { icon: string; label: string; value: string }) {
return (
<div className="bg-white rounded-lg border p-4">
<div className="text-2xl mb-1">{icon}</div>
<div className="text-2xl font-bold">{value}</div>
<div className="text-sm text-muted-foreground">{label}</div>
</div>
);
}PatternSection function · typescript · L284-L335 (52 LOC)src/app/dashboard/analytics/page.tsx
function PatternSection({ insight }: { insight: PerformanceInsight | null }) {
if (!insight) {
return (
<div className="bg-white rounded-lg border p-8 text-center">
<div className="text-3xl mb-3">🔬</div>
<p className="text-sm text-muted-foreground">
패턴 분석에는 최소 3건의 성과 데이터가 필요합니다
</p>
</div>
);
}
const allPatterns = [...insight.topPatterns, ...insight.bottomPatterns];
if (allPatterns.length === 0) return null;
const maxEngagement = Math.max(...allPatterns.map((p) => p.avgEngagement), 1);
return (
<div className="bg-white rounded-lg border">
<div className="p-4 border-b">
<h2 className="text-base font-semibold">패턴 성과 분석</h2>
<p className="text-xs text-muted-foreground mt-1">
최근 {insight.periodDays}일 / {insight.totalSamples}건 분석
</p>
</div>
<div className="divide-y">
{insight.topPatterns.length > 0 && (
<div className="p-4">
<h3 classNapage 1 / 5next ›