Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
# Only needed if you use service role in Route Handlers or Server Actions.
# NEVER expose this key to the browser or prefix with NEXT_PUBLIC_.
# SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here

# --- OpenAI (server-only) -----------------------------------
# Find this in: https://platform.openai.com/ → API Keys
# NEVER expose this key to the browser or prefix with NEXT_PUBLIC_.
OPENAI_API_KEY=sk-...
15 changes: 0 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,9 @@ on:
branches: [main]

jobs:
security:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Scan for secrets
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

quality:
name: Lint, Build & Test
runs-on: ubuntu-latest
needs: [security]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# hackathon-starter Development Guidelines

## Active Technologies
- TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5 (001-hirameki-battle)
- Supabase PostgreSQL — `game_scores` table (see data-model.md) (001-hirameki-battle)

- TypeScript 5.3+ + Next.js 15 (App Router), @supabase/supabase-js v2, @supabase/ssr v0.1+, Biome v1.8+, Vitest v1+

Expand Down Expand Up @@ -61,3 +63,6 @@ git commit -m "chore: sync template improvements from <dev-branch>"
See CONTRIBUTING.md for the full workflow.

<!-- MANUAL ADDITIONS END -->

## Recent Changes
- 001-hirameki-battle: Added TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5
16 changes: 8 additions & 8 deletions app/(auth)/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";

/**
* Handles OAuth provider callbacks and magic link confirmations.
* Supabase redirects here after the user authenticates externally.
*/
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get("code")
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");

if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);

if (!error) {
return NextResponse.redirect(`${origin}/`)
return NextResponse.redirect(`${origin}/`);
}
}

// Return to an error page if the code exchange fails or no code is present
return NextResponse.redirect(`${origin}/auth/error`)
return NextResponse.redirect(`${origin}/auth/error`);
}
44 changes: 44 additions & 0 deletions app/api/generate-topic/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getOpenAIClient } from "@/lib/openai";
import OpenAI from "openai";
import { NextResponse } from "next/server";

export async function POST() {
try {
const completion = await getOpenAIClient().chat.completions.create({
model: "gpt-4o",
max_tokens: 128,
messages: [
{
role: "user",
content:
"日本語で、日常的なモノや状況を対象にした「○○の新しい使い方・活用法」形式のお題を1つ生成してください。お題のテキストだけを返してください。例:「傘の新しい使い方」",
},
],
});

const text = completion.choices[0]?.message?.content?.trim();
if (!text) {
return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 });
}

return NextResponse.json({ topic: text });
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
return NextResponse.json(
{
error: "リクエストが多すぎます。しばらく待ってから再試行してください。",
retryAfter: 60,
},
{ status: 429 },
);
}
if (err instanceof OpenAI.APIConnectionError) {
return NextResponse.json(
{ error: "トピック生成サービスが一時的に利用できません。再試行してください。" },
{ status: 503 },
);
}
console.error("[generate-topic] Unexpected error:", err);
return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 });
}
}
18 changes: 9 additions & 9 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createClient } from "@/lib/supabase/server"
import { NextResponse } from "next/server"
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";

/**
* GET /api/health
Expand All @@ -8,26 +8,26 @@ import { NextResponse } from "next/server"
*/
export async function GET() {
try {
const supabase = await createClient()
const { error } = await supabase.from("demo_items").select("id").limit(1)
const supabase = await createClient();
const { error } = await supabase.from("demo_items").select("id").limit(1);

if (error) {
console.error("[health] DB query error:", error.message)
console.error("[health] DB query error:", error.message);
return NextResponse.json(
{ status: "error", message: "Database connection failed" },
{ status: 503 },
)
);
}

return NextResponse.json({
status: "ok",
timestamp: new Date().toISOString(),
})
});
} catch (err) {
console.error("[health] Unexpected error:", err)
console.error("[health] Unexpected error:", err);
return NextResponse.json(
{ status: "error", message: "Internal server error" },
{ status: 500 },
)
);
}
}
131 changes: 131 additions & 0 deletions app/api/score-idea/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { getOpenAIClient } from "@/lib/openai";
import { insertGameScore } from "@/lib/game-scores";
import OpenAI from "openai";
import { type NextRequest, NextResponse } from "next/server";

const SCORE_FUNCTION: OpenAI.Chat.Completions.ChatCompletionTool = {
type: "function",
function: {
name: "submit_score",
description: "プレイヤーのアイデアを3軸で採点し、一言コメントを返す",
parameters: {
type: "object",
properties: {
originality: {
type: "number",
description: "独創性スコア(0〜33点):既成概念にとらわれない独自性",
},
practicality: {
type: "number",
description: "実用性スコア(0〜33点):実際に使えそうか・現実的か",
},
unexpectedness: {
type: "number",
description: "意外性スコア(0〜34点):予想外・驚きがあるか",
},
comment: {
type: "string",
description: "採点理由・講評を含む一言コメント(日本語)",
},
},
required: ["originality", "practicality", "unexpectedness", "comment"],
},
},
};

export async function POST(request: NextRequest) {
let body: { sessionId?: string; topic?: string; idea?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 });
}

const { sessionId, topic, idea } = body;

if (!sessionId || !topic) {
return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 });
}
if (!idea || idea.trim().length === 0) {
return NextResponse.json({ error: "アイデアを入力してください。" }, { status: 400 });
}
if (idea.length > 500) {
return NextResponse.json(
{ error: "アイデアは500文字以内で入力してください。" },
{ status: 400 },
);
}

try {
const completion = await getOpenAIClient().chat.completions.create({
model: "gpt-4o",
max_tokens: 512,
tools: [SCORE_FUNCTION],
tool_choice: { type: "function", function: { name: "submit_score" } },
messages: [
{
role: "user",
content: `お題:「${topic}」\nプレイヤーのアイデア:「${idea}」\n\nこのアイデアを独創性・実用性・意外性の3軸で採点し、一言コメントを付けてください。`,
},
],
});

const toolCall = completion.choices[0]?.message?.tool_calls?.[0];
if (!toolCall || toolCall.type !== "function") {
return NextResponse.json(
{ error: "採点に失敗しました。再試行してください。" },
{ status: 500 },
);
}

const scores = JSON.parse(toolCall.function.arguments) as {
originality: number;
practicality: number;
unexpectedness: number;
comment: string;
};

const totalScore = scores.originality + scores.practicality + scores.unexpectedness;

const saved = await insertGameScore({
sessionId,
topic,
userIdea: idea,
scoreOriginality: scores.originality,
scorePracticality: scores.practicality,
scoreUnexpectedness: scores.unexpectedness,
totalScore,
aiComment: scores.comment,
});

return NextResponse.json({
scoreId: saved.id,
originality: scores.originality,
practicality: scores.practicality,
unexpectedness: scores.unexpectedness,
totalScore,
comment: scores.comment,
});
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
return NextResponse.json(
{
error: "リクエストが多すぎます。しばらく待ってから再試行してください。",
retryAfter: 60,
},
{ status: 429 },
);
}
if (err instanceof OpenAI.APIConnectionError) {
return NextResponse.json(
{ error: "採点サービスが一時的に利用できません。入力内容は保持されています。" },
{ status: 503 },
);
}
console.error("[score-idea] Unexpected error:", err);
return NextResponse.json(
{ error: "採点に失敗しました。再試行してください。" },
{ status: 500 },
);
}
}
40 changes: 40 additions & 0 deletions app/api/scores/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getGamesPlayed, getPersonalBest, getRecentScores } from "@/lib/game-scores";
import type { GameScore } from "@/types/database";
import type { ScoreHistoryItem } from "@/types/game";
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("sessionId");
const limitParam = searchParams.get("limit");

if (!sessionId) {
return NextResponse.json({ error: "sessionIdは必須です。" }, { status: 400 });
}

const limit = limitParam ? Math.min(Number(limitParam), 100) : 10;

try {
const [rawScores, personalBest, gamesPlayed] = await Promise.all([
getRecentScores(sessionId, limit),
getPersonalBest(sessionId),
getGamesPlayed(sessionId),
]);

const scores: ScoreHistoryItem[] = rawScores.map((s: GameScore) => ({
id: s.id,
topic: s.topic,
totalScore: s.total_score,
originality: s.score_originality,
practicality: s.score_practicality,
unexpectedness: s.score_unexpectedness,
comment: s.ai_comment,
playedAt: s.played_at,
}));

return NextResponse.json({ scores, personalBest, gamesPlayed });
} catch (err) {
console.error("[scores] Error fetching scores:", err);
return NextResponse.json({ error: "スコアの取得に失敗しました。" }, { status: 500 });
}
}
14 changes: 7 additions & 7 deletions app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client"
"use client";

import { useEffect } from "react"
import { useEffect } from "react";

interface ErrorProps {
error: Error & { digest?: string }
reset: () => void
error: Error & { digest?: string };
reset: () => void;
}

/**
Expand All @@ -13,8 +13,8 @@ interface ErrorProps {
*/
export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
console.error("[Error boundary]", error)
}, [error])
console.error("[Error boundary]", error);
}, [error]);

return (
<main
Expand Down Expand Up @@ -49,5 +49,5 @@ export default function Error({ error, reset }: ErrorProps) {
Try again
</button>
</main>
)
);
}
Loading
Loading