diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9000b74 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "local-plugins", + "interface": { + "displayName": "Local Plugins" + }, + "plugins": [ + { + "name": "subagent-orchestration-kit", + "source": { + "source": "local", + "path": "./plugins/subagent-orchestration-kit" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.env.example b/.env.example index 46464bc..c0dc91d 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,19 @@ # .env は絶対にコミットしないこと # === Slack === -SLACK_BOT_TOKEN= -SLACK_SIGNING_SECRET= -SLACK_APP_ID= +SLACK_BOT_TOKEN= # xoxb-... (OAuth & Permissions) +SLACK_APP_TOKEN= # xapp-... (Basic Information → App-Level Tokens → connections:write) +SLACK_APP_ID= # A0XXXXXXX (setup.sh で使用) +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +SLACK_REDIRECT_URI= +SLACK_INSTALLATION_ENCRYPTION_SECRET= +SLACK_SIGNING_SECRET= # 不要になりましたが念のため残す SLACK_MENTOR_CHANNEL_ID= SLACK_PROGRESS_CHANNEL_ID= # === Database === -DATABASE_URL=postgres://postgres:postgres@localhost:5432/kcl_support_hub +DATABASE_URL=postgres://postgres:postgres@localhost:5432/kcl_support_hub?sslmode=disable # === AI (Bonsai / Ollama) === # ollama serve && ollama pull qwen2.5:7b を実行してから設定 @@ -19,10 +24,22 @@ BONSAI_BASE_URL=http://localhost:11434/v1 BONSAI_API_KEY=ollama BONSAI_MODEL=qwen2.5:7b +# === AI (KIT AI endpoint) === +# 社内 AI サーバーを使う場合はこちらを設定 +KIT_AI_URL=https://aisvr221.aikb.kyutech.ac.jp/api/generate +KIT_AI_PW= +KIT_AI_MODEL=gpt-oss:120b +KIT_AI_VERIFY_TLS=false + # === AI Fallback (optional) === # どちらか一方を設定すると Bonsai 落ちたときの fallback が有効になる OPENAI_API_KEY= ANTHROPIC_API_KEY= +GITHUB_TOKEN= # judge digest の運営 fallback / public repo rate limit 緩和用(participant 連携が優先) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI=http://localhost:3001/github/oauth/callback +GITHUB_CONNECTION_ENCRYPTION_SECRET= # === AI 設定 === AI_CONFIDENCE_THRESHOLD=0.5 @@ -34,7 +51,7 @@ ONYX_API_URL= ONYX_API_KEY= # === AWS === -AWS_REGION=ap-northeast-1 +AWS_REGION=us-east-1 # ローカル開発 (LocalStack) 用 AWS_ENDPOINT_URL=http://localhost:4566 AWS_ACCESS_KEY_ID=test @@ -47,8 +64,10 @@ SQS_PROGRESS_URL=http://localhost:4566/000000000000/progress # === API Server === PORT=8080 ENV=development +HACKHUB_INTERNAL_AUTH_SECRET=dev-internal-auth-secret-change-in-production -# === Web Dashboard === +# === Control Plane === NEXT_PUBLIC_API_URL=http://localhost:8080 -NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_URL=http://localhost:3001 NEXTAUTH_SECRET=dev-secret-change-in-production +CONSOLE_SESSION_SECRET=dev-console-session-secret-change-in-production diff --git a/.gitignore b/.gitignore index d6cd1ec..cc49003 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ Thumbs.db .vscode/ *.swp +# Local runtime state +.tmp/ + # VitePress docs-host/.vitepress/cache docs-host/.vitepress/dist diff --git a/CLAUDE.md b/CLAUDE.md index b647544..8def631 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# CLAUDE.md — KCL Support Hub +# CLAUDE.md — HackHub Claude Code がこのリポジトリで作業するときに読む指示書です。 @@ -6,7 +6,7 @@ Claude Code がこのリポジトリで作業するときに読む指示書で ## 最初にやること -1. `docs/specification.md` を読んでプロダクト全体を把握する +1. `docs/specification.md` を読んで、support hub と showcase / talent の両トラックを把握する 2. 作業対象に関連する `docs/detail/` のファイルを読む 3. `AGENTS.md` のルール(3段階境界・DoD)を確認する diff --git a/Makefile b/Makefile index 5227b12..e6f8a07 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,12 @@ export .PHONY: help \ up down logs \ + init-sqs \ migrate migrate-down migrate-status \ - dev-api dev-worker dev-web \ - docs docs-build \ + dev-api dev-worker dev-web dev-console dev-control-plane \ + docs \ build \ - slack-setup slack-manifest \ + slack-manifest \ lint test \ setup @@ -16,12 +17,13 @@ export # ----------------------------------------------- help: @echo "" - @echo "KCL Support Hub — Makefile" + @echo "HackHub — Makefile" @echo "" @echo " インフラ" @echo " make up Docker インフラ起動 (postgres + localstack)" @echo " make down Docker インフラ停止" @echo " make logs Docker ログ表示" + @echo " make init-sqs LocalStack SQS キュー作成 (up後に必要)" @echo "" @echo " DB" @echo " make migrate マイグレーション実行" @@ -32,21 +34,22 @@ help: @echo " make dev-api Go API サーバー起動 (port 8080)" @echo " make dev-worker TypeScript Worker 起動" @echo " make dev-web Next.js ダッシュボード起動 (port 3000)" + @echo " make dev-control-plane Next.js control-plane 起動 (port 3001)" + @echo " make dev-console 互換 alias (legacy name)" + @echo " 起動後: http://localhost:3001/signup からアカウント作成" @echo " make docs ドキュメントサーバー起動 (port 4000)" - @echo " make docs-build ドキュメントを静的ファイルにビルド" @echo "" @echo " Slack" - @echo " make slack-setup URL=https://xxxx.ngrok-free.app" - @echo " Slash Commands + Interactivity URL を一括更新" @echo " make slack-manifest manifest.json の内容を表示" + @echo " ※ Socket Mode 使用中のため ngrok / URL 設定不要" @echo "" @echo " ビルド / テスト" @echo " make build 全サービスをビルド" @echo " make lint 全サービスのリント" - @echo " make test 全サービスのテスト" + @echo " make test 利用可能なテスト / 型検査" @echo "" @echo " 初回セットアップ一括" - @echo " make setup up + migrate をまとめて実行" + @echo " make setup up + init-sqs + migrate をまとめて実行" @echo "" # ----------------------------------------------- @@ -55,6 +58,9 @@ help: up: docker compose -f infra/docker/compose.yml up -d +init-sqs: + docker exec docker-localstack-1 bash /etc/localstack/init/ready.d/init-localstack.sh + down: docker compose -f infra/docker/compose.yml down @@ -80,29 +86,26 @@ dev-api: $(MAKE) -C apps/api dev dev-worker: - cd apps/worker && pnpm dev + ./scripts/dev-worker.sh dev-web: - cd apps/web && pnpm dev + cd apps/web && PORT=3000 pnpm dev + +dev-console: + cd apps/console && PORT=3001 pnpm dev + +dev-control-plane: + cd apps/console && PORT=3001 pnpm dev # ----------------------------------------------- # ドキュメント # ----------------------------------------------- docs: - cd docs-host && pnpm dev - -docs-build: - cd docs-host && pnpm build + node docs-host/server.js # ----------------------------------------------- # Slack # ----------------------------------------------- -slack-setup: -ifndef URL - $(error URLを指定してください: make slack-setup URL=https://xxxx.ngrok-free.app) -endif - ./infra/slack/setup.sh "$(URL)" - slack-manifest: @cat infra/slack/manifest.json @@ -113,6 +116,7 @@ build: $(MAKE) -C apps/api build cd apps/worker && pnpm build cd apps/web && pnpm build + cd apps/console && pnpm build # ----------------------------------------------- # リント / テスト @@ -121,10 +125,12 @@ lint: $(MAKE) -C apps/api lint cd apps/worker && pnpm lint cd apps/web && pnpm lint + cd apps/console && pnpm lint test: $(MAKE) -C apps/api test - cd apps/worker && pnpm test + cd apps/worker && pnpm typecheck + cd apps/console && pnpm typecheck # ----------------------------------------------- # 初回セットアップ一括 @@ -132,11 +138,11 @@ test: setup: up @echo "インフラ起動を待機中..." @sleep 5 + $(MAKE) init-sqs $(MAKE) migrate @echo "" @echo "セットアップ完了。次のステップ:" @echo " 1. make dev-api (別ターミナル)" @echo " 2. make dev-worker (別ターミナル)" @echo " 3. make dev-web (別ターミナル)" - @echo " 4. ngrok http 8080" - @echo " 5. make slack-setup URL=" + @echo " 4. make dev-control-plane (別ターミナル)" diff --git a/README.md b/README.md index 3b1a0d6..501dd22 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# KCL Support Hub +# HackHub -KCL(初心者向けハッカソン)の質問受付・進捗可視化・AI 一次対応システム。 +HackHub は、`progress-checker` の support hub と `hack-evaluater` の showcase / talent product を同時に提供する multi-hackathon platform です。 -Slack を主入口として、Bonsai-8B(ローカル LLM)が質問をトリアージし、メンターへのエスカレーションを支援します。 +Slack を主入口にした質問受付・進捗可視化・AI 一次対応に加えて、GitHub repository の発表要約、ハッカソン実績からの portfolio 出力、Sponsor 向け scout workflow を同じ権限モデルの上に載せます。 +現在の `dev-codex` 系統では、`apps/console` をその統合 control-plane として拡張しています。 +最新 slice では、console のローカル認証、アカウント作成、ロール切替、Slack 導入案内、Sponsor / Hacker の scout onboarding、Organizer 向け team / Slack channel mapping UI、Judge 向け GitHub digest、Hacker 向け portfolio 下書きと Markdown export、dark mode、署名付き internal headers による API scope 接続まで入っています。 ## ドキュメント @@ -10,6 +12,7 @@ Slack を主入口として、Bonsai-8B(ローカル LLM)が質問をトリ - [アーキテクチャ](./docs/detail/02_architecture.md) - [AI フロー](./docs/detail/03_ai-flow.md) - [実装フェーズ](./docs/detail/05_implementation-phases.md) +- [統合差分と opportunity cycle](./docs/detail/10_combined-product-gap-analysis.md) - [AI コーディング環境セットアップ](./docs/ai-setup.md) ## セットアップ @@ -22,3 +25,25 @@ docker compose -f infra/docker/compose.yml up -d ``` 詳細は [`docs/detail/07_env-vars.md`](./docs/detail/07_env-vars.md) を参照。 + +## 開発用アプリ + +- `apps/web` : 既存の read-only dashboard +- `apps/console` : support hub + showcase / talent の両導線を載せる統合 control-plane + - `dev-codex` では `/login` から role / hackathon scope session を作って protected route を切り替える + - `/tenant` でハッカソン登録、`/organizer` で team / Slack channel mapping を行える + - `/judge` で GitHub repository から発表用 digest を生成して履歴保存できる + - `/hacker` で participant GitHub 連携、portfolio 下書き保存、Markdown export ができる + - `/sponsor` で可視範囲内の参加者へ scout を送れる + - `make dev-control-plane` が推奨起動コマンド。`make dev-console` は互換 alias + +## ローカル確認の最短手順 + +1. `make up` +2. `make init-sqs` +3. `make migrate` +4. `make dev-api` +5. `make dev-worker` +6. `make dev-control-plane` + +その後、`http://localhost:3001/signup` でアカウントを作成し、`/login` からログインします。Slack App の導入確認は `http://localhost:3001/slack/install` から、participant の GitHub 連携は `http://localhost:3001/hacker` から行えます。 diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 8c19a79..eb645ed 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -36,6 +36,10 @@ func main() { log.Fatalf("sqs init: %v", err) } + // Start Slack Socket Mode in a background goroutine. + // Connects to Slack via WebSocket — no public URL required. + go slackhandler.RunSocketMode(cfg, sqlDB, sqsClient) + if !cfg.IsDev() { gin.SetMode(gin.ReleaseMode) } @@ -46,17 +50,18 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - // Slack webhook routes - r.POST("/slack/commands", slackhandler.HandleSlashCommand(cfg, sqlDB)) - r.POST("/slack/interactions", slackhandler.HandleInteraction(cfg, sqlDB, sqsClient)) - // REST API routes for the dashboard api := r.Group("/api") { - api.GET("/questions", apihandler.HandleListQuestions(sqlDB)) - api.GET("/questions/:id", apihandler.HandleGetQuestion(sqlDB)) - api.GET("/progress", apihandler.HandleListProgress(sqlDB)) - api.GET("/teams", apihandler.HandleListTeams(sqlDB)) + api.GET("/hackathons", apihandler.HandleListHackathons(sqlDB, cfg.InternalAuthSecret)) + api.POST("/hackathons", apihandler.HandleCreateHackathon(sqlDB, cfg.InternalAuthSecret)) + api.GET("/questions", apihandler.HandleListQuestions(sqlDB, cfg.InternalAuthSecret)) + api.GET("/questions/:id", apihandler.HandleGetQuestion(sqlDB, cfg.InternalAuthSecret)) + api.PATCH("/questions/:id/status", apihandler.HandleUpdateQuestionStatus(sqlDB, cfg.InternalAuthSecret)) + api.GET("/progress", apihandler.HandleListProgress(sqlDB, cfg.InternalAuthSecret)) + api.GET("/teams", apihandler.HandleListTeams(sqlDB, cfg.InternalAuthSecret)) + api.POST("/teams", apihandler.HandleCreateTeam(sqlDB, cfg.InternalAuthSecret)) + api.PATCH("/teams/:id", apihandler.HandleUpdateTeam(sqlDB, cfg.InternalAuthSecret)) } log.Printf("starting server on :%s (env=%s)", cfg.Port, cfg.Env) diff --git a/apps/api/db/migrations/007_create_hackathons.sql b/apps/api/db/migrations/007_create_hackathons.sql new file mode 100644 index 0000000..30cf0c3 --- /dev/null +++ b/apps/api/db/migrations/007_create_hackathons.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE hackathons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE teams + ADD COLUMN hackathon_id UUID REFERENCES hackathons (id), + ADD COLUMN slack_workspace_id TEXT; + +INSERT INTO hackathons (id, slug, name) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'default-hackathon', + 'Default Hackathon' +); + +UPDATE teams +SET hackathon_id = '00000000-0000-0000-0000-000000000001' +WHERE hackathon_id IS NULL; + +ALTER TABLE teams + ALTER COLUMN hackathon_id SET NOT NULL; + +CREATE INDEX idx_teams_hackathon_id ON teams (hackathon_id); +CREATE INDEX idx_teams_slack_scope ON teams (slack_workspace_id, slack_channel_id); + +-- +goose Down +DROP INDEX idx_teams_slack_scope; +DROP INDEX idx_teams_hackathon_id; + +ALTER TABLE teams + DROP COLUMN slack_workspace_id, + DROP COLUMN hackathon_id; + +DROP TABLE hackathons; diff --git a/apps/api/db/migrations/008_create_question_sessions.sql b/apps/api/db/migrations/008_create_question_sessions.sql new file mode 100644 index 0000000..96a0995 --- /dev/null +++ b/apps/api/db/migrations/008_create_question_sessions.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE TABLE question_sessions ( + question_id UUID PRIMARY KEY REFERENCES questions (id) ON DELETE CASCADE, + status TEXT NOT NULL, + requested_missing_info JSONB NOT NULL DEFAULT '[]'::jsonb, + last_user_message TEXT, + last_ai_answer TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_question_sessions_status ON question_sessions (status); + +-- +goose Down +DROP INDEX idx_question_sessions_status; + +DROP TABLE question_sessions; diff --git a/apps/api/db/migrations/009_create_console_auth.sql b/apps/api/db/migrations/009_create_console_auth.sql new file mode 100644 index 0000000..a04ade2 --- /dev/null +++ b/apps/api/db/migrations/009_create_console_auth.sql @@ -0,0 +1,95 @@ +-- +goose Up +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + auth_subject TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + email_verified_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_role_bindings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role TEXT NOT NULL, + scope_type TEXT NOT NULL, + hackathon_id UUID REFERENCES hackathons (id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + CHECK (role IN ('platform_admin', 'tenant_admin', 'hackathon_organizer', 'mentor', 'sponsor', 'judge', 'hacker')), + CHECK (scope_type IN ('global', 'hackathon')), + CHECK ( + (scope_type = 'global' AND hackathon_id IS NULL) + OR (scope_type = 'hackathon' AND hackathon_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_user_role_bindings_active_unique + ON user_role_bindings (user_id, role, COALESCE(hackathon_id, '00000000-0000-0000-0000-000000000000'::UUID)) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_user_role_bindings_lookup + ON user_role_bindings (user_id, role, hackathon_id) + WHERE revoked_at IS NULL; + +CREATE TABLE hackathon_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + hackathon_id UUID NOT NULL REFERENCES hackathons (id) ON DELETE CASCADE, + membership_role TEXT NOT NULL, + is_scout_allowed BOOLEAN NOT NULL DEFAULT TRUE, + status TEXT NOT NULL DEFAULT 'active', + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + left_at TIMESTAMPTZ, + CHECK (membership_role IN ('judge', 'hacker')) +); + +CREATE UNIQUE INDEX idx_hackathon_memberships_active_unique + ON hackathon_memberships (user_id, hackathon_id, membership_role) + WHERE left_at IS NULL; + +CREATE TABLE sponsor_visible_hackathons ( + sponsor_role_binding_id UUID NOT NULL REFERENCES user_role_bindings (id) ON DELETE CASCADE, + hackathon_id UUID NOT NULL REFERENCES hackathons (id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (sponsor_role_binding_id, hackathon_id) +); + +CREATE TABLE scouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hackathon_id UUID NOT NULL REFERENCES hackathons (id) ON DELETE CASCADE, + sender_user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + sender_role_binding_id UUID NOT NULL REFERENCES user_role_bindings (id) ON DELETE CASCADE, + recipient_membership_id UUID NOT NULL REFERENCES hackathon_memberships (id) ON DELETE CASCADE, + subject TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'sent', + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_scouts_sender_role_binding_id ON scouts (sender_role_binding_id, created_at DESC); +CREATE INDEX idx_scouts_recipient_membership_id ON scouts (recipient_membership_id, created_at DESC); +CREATE INDEX idx_scouts_hackathon_id ON scouts (hackathon_id, created_at DESC); + +-- +goose Down +DROP INDEX idx_scouts_hackathon_id; +DROP INDEX idx_scouts_recipient_membership_id; +DROP INDEX idx_scouts_sender_role_binding_id; +DROP TABLE scouts; + +DROP TABLE sponsor_visible_hackathons; + +DROP INDEX idx_hackathon_memberships_active_unique; +DROP TABLE hackathon_memberships; + +DROP INDEX idx_user_role_bindings_lookup; +DROP INDEX idx_user_role_bindings_active_unique; +DROP TABLE user_role_bindings; + +DROP TABLE users; diff --git a/apps/api/db/migrations/010_create_presentation_digests.sql b/apps/api/db/migrations/010_create_presentation_digests.sql new file mode 100644 index 0000000..dfd9e33 --- /dev/null +++ b/apps/api/db/migrations/010_create_presentation_digests.sql @@ -0,0 +1,24 @@ +-- +goose Up +CREATE TABLE presentation_digests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hackathon_id UUID NOT NULL REFERENCES hackathons (id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + team_name TEXT NOT NULL, + repository_url TEXT, + repository_full_name TEXT, + readme_snapshot TEXT NOT NULL DEFAULT '', + input_notes TEXT NOT NULL DEFAULT '', + one_minute_summary TEXT NOT NULL, + technical_highlights TEXT[] NOT NULL DEFAULT '{}', + judging_prompts TEXT[] NOT NULL DEFAULT '{}', + provider TEXT NOT NULL, + model TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_presentation_digests_hackathon_created_at + ON presentation_digests (hackathon_id, created_at DESC); + +-- +goose Down +DROP INDEX idx_presentation_digests_hackathon_created_at; +DROP TABLE presentation_digests; diff --git a/apps/api/db/migrations/011_create_portfolio_entries.sql b/apps/api/db/migrations/011_create_portfolio_entries.sql new file mode 100644 index 0000000..7e499db --- /dev/null +++ b/apps/api/db/migrations/011_create_portfolio_entries.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE portfolio_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + membership_id UUID NOT NULL UNIQUE REFERENCES hackathon_memberships (id) ON DELETE CASCADE, + project_name TEXT NOT NULL DEFAULT '', + project_summary TEXT NOT NULL DEFAULT '', + contribution_summary TEXT NOT NULL DEFAULT '', + repository_url TEXT, + demo_url TEXT, + tech_stack TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + outcome_summary TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_portfolio_entries_membership_id + ON portfolio_entries (membership_id); + +-- +goose Down +DROP INDEX idx_portfolio_entries_membership_id; +DROP TABLE portfolio_entries; diff --git a/apps/api/db/migrations/012_create_slack_installations.sql b/apps/api/db/migrations/012_create_slack_installations.sql new file mode 100644 index 0000000..626794d --- /dev/null +++ b/apps/api/db/migrations/012_create_slack_installations.sql @@ -0,0 +1,23 @@ +-- +goose Up +CREATE TABLE slack_installations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id TEXT NOT NULL UNIQUE, + workspace_name TEXT NOT NULL, + workspace_url TEXT, + enterprise_id TEXT, + enterprise_name TEXT, + bot_user_id TEXT, + scope TEXT NOT NULL DEFAULT '', + access_token_hint TEXT, + installed_by_user_id UUID REFERENCES users (id) ON DELETE SET NULL, + installed_by_slack_user_id TEXT, + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_slack_installations_installed_by_user_id + ON slack_installations (installed_by_user_id, installed_at DESC); + +-- +goose Down +DROP INDEX idx_slack_installations_installed_by_user_id; +DROP TABLE slack_installations; diff --git a/apps/api/db/migrations/013_add_slack_installation_token_ciphertext.sql b/apps/api/db/migrations/013_add_slack_installation_token_ciphertext.sql new file mode 100644 index 0000000..b8fb8fb --- /dev/null +++ b/apps/api/db/migrations/013_add_slack_installation_token_ciphertext.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE slack_installations + ADD COLUMN bot_access_token_ciphertext TEXT; + +-- +goose Down +ALTER TABLE slack_installations + DROP COLUMN bot_access_token_ciphertext; diff --git a/apps/api/db/migrations/014_create_github_connections.sql b/apps/api/db/migrations/014_create_github_connections.sql new file mode 100644 index 0000000..0b8dc59 --- /dev/null +++ b/apps/api/db/migrations/014_create_github_connections.sql @@ -0,0 +1,21 @@ +-- +goose Up +CREATE TABLE github_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users (id) ON DELETE CASCADE, + github_user_id TEXT NOT NULL, + github_login TEXT NOT NULL, + github_name TEXT, + scope TEXT NOT NULL DEFAULT '', + access_token_hint TEXT, + access_token_ciphertext TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_github_connections_login + ON github_connections (github_login, updated_at DESC); + +-- +goose Down +DROP INDEX idx_github_connections_login; +DROP TABLE github_connections; diff --git a/apps/api/db/queries/hackathons.sql b/apps/api/db/queries/hackathons.sql new file mode 100644 index 0000000..ebf7f9d --- /dev/null +++ b/apps/api/db/queries/hackathons.sql @@ -0,0 +1,16 @@ +-- name: ListHackathons :many +SELECT + * +FROM + hackathons +ORDER BY + created_at ASC, + name ASC; + +-- name: InsertHackathon :one +INSERT INTO + hackathons (slug, name) +VALUES + ($1, $2) +RETURNING + *; diff --git a/apps/api/db/queries/progress.sql b/apps/api/db/queries/progress.sql index 80a13b1..21fcc26 100644 --- a/apps/api/db/queries/progress.sql +++ b/apps/api/db/queries/progress.sql @@ -16,20 +16,25 @@ RETURNING -- name: ListProgressLogs :many SELECT - * + progress_logs.* FROM progress_logs + LEFT JOIN teams ON teams.id = progress_logs.team_id WHERE ( sqlc.narg (team_id)::uuid IS NULL - OR team_id = sqlc.narg (team_id)::uuid + OR progress_logs.team_id = sqlc.narg (team_id)::uuid + ) + AND ( + sqlc.narg (hackathon_id)::uuid IS NULL + OR teams.hackathon_id = sqlc.narg (hackathon_id)::uuid ) ORDER BY - created_at DESC + progress_logs.created_at DESC LIMIT - $2 + sqlc.arg(page_limit)::int OFFSET - $3; + sqlc.arg(page_offset)::int; -- name: LatestProgressPerTeam :many SELECT DISTINCT diff --git a/apps/api/db/queries/questions.sql b/apps/api/db/queries/questions.sql index 76820df..a56048d 100644 --- a/apps/api/db/queries/questions.sql +++ b/apps/api/db/queries/questions.sql @@ -35,20 +35,25 @@ WHERE -- name: ListQuestions :many SELECT - * + questions.* FROM questions + LEFT JOIN teams ON teams.id = questions.team_id WHERE ( sqlc.narg (status)::text IS NULL - OR status = sqlc.narg (status) + OR questions.status = sqlc.narg (status)::text + ) + AND ( + sqlc.narg (hackathon_id)::uuid IS NULL + OR teams.hackathon_id = sqlc.narg (hackathon_id)::uuid ) ORDER BY - created_at DESC + questions.created_at DESC LIMIT - $2 + sqlc.arg(page_limit)::int OFFSET - $3; + sqlc.arg(page_offset)::int; -- name: UpdateQuestionStatus :exec UPDATE questions diff --git a/apps/api/db/queries/teams.sql b/apps/api/db/queries/teams.sql index 7e6902b..adf90fa 100644 --- a/apps/api/db/queries/teams.sql +++ b/apps/api/db/queries/teams.sql @@ -11,14 +11,27 @@ SELECT * FROM teams +WHERE + ( + sqlc.narg (hackathon_id)::uuid IS NULL + OR hackathon_id = sqlc.narg (hackathon_id)::uuid + ) ORDER BY name; -- name: InsertTeam :one INSERT INTO - teams (name, slack_channel_id, tech_stack, phase, is_sos) + teams ( + name, + slack_channel_id, + tech_stack, + phase, + is_sos, + hackathon_id, + slack_workspace_id + ) VALUES - ($1, $2, $3, $4, $5) + ($1, $2, $3, $4, $5, $6, $7) RETURNING *; @@ -37,3 +50,18 @@ SET updated_at = NOW() WHERE id = $1; + +-- name: UpdateTeamSetup :one +UPDATE teams +SET + name = $2, + slack_channel_id = $3, + tech_stack = $4, + phase = $5, + is_sos = $6, + slack_workspace_id = $7, + updated_at = NOW() +WHERE + id = $1 +RETURNING + *; diff --git a/apps/api/db/sqlcgen/hackathons.sql.go b/apps/api/db/sqlcgen/hackathons.sql.go new file mode 100644 index 0000000..5cae0a1 --- /dev/null +++ b/apps/api/db/sqlcgen/hackathons.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: hackathons.sql + +package sqlcgen + +import ( + "context" +) + +const insertHackathon = `-- name: InsertHackathon :one +INSERT INTO + hackathons (slug, name) +VALUES + ($1, $2) +RETURNING + id, slug, name, created_at, updated_at +` + +type InsertHackathonParams struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +func (q *Queries) InsertHackathon(ctx context.Context, arg InsertHackathonParams) (Hackathon, error) { + row := q.db.QueryRowContext(ctx, insertHackathon, arg.Slug, arg.Name) + var i Hackathon + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listHackathons = `-- name: ListHackathons :many +SELECT + id, slug, name, created_at, updated_at +FROM + hackathons +ORDER BY + created_at ASC, + name ASC +` + +func (q *Queries) ListHackathons(ctx context.Context) ([]Hackathon, error) { + rows, err := q.db.QueryContext(ctx, listHackathons) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Hackathon{} + for rows.Next() { + var i Hackathon + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/apps/api/db/sqlcgen/models.go b/apps/api/db/sqlcgen/models.go index f9665ec..38f3269 100644 --- a/apps/api/db/sqlcgen/models.go +++ b/apps/api/db/sqlcgen/models.go @@ -6,12 +6,32 @@ package sqlcgen import ( "database/sql" + "encoding/json" "time" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" ) +type Hackathon struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type HackathonMembership struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + HackathonID uuid.UUID `json:"hackathon_id"` + MembershipRole string `json:"membership_role"` + IsScoutAllowed bool `json:"is_scout_allowed"` + Status string `json:"status"` + JoinedAt time.Time `json:"joined_at"` + LeftAt sql.NullTime `json:"left_at"` +} + type KnowledgeItem struct { ID uuid.UUID `json:"id"` Category string `json:"category"` @@ -67,6 +87,30 @@ type QuestionFollowup struct { CreatedAt time.Time `json:"created_at"` } +type QuestionSession struct { + QuestionID uuid.UUID `json:"question_id"` + Status string `json:"status"` + RequestedMissingInfo json.RawMessage `json:"requested_missing_info"` + LastUserMessage sql.NullString `json:"last_user_message"` + LastAiAnswer sql.NullString `json:"last_ai_answer"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Scout struct { + ID uuid.UUID `json:"id"` + HackathonID uuid.UUID `json:"hackathon_id"` + SenderUserID uuid.UUID `json:"sender_user_id"` + SenderRoleBindingID uuid.UUID `json:"sender_role_binding_id"` + RecipientMembershipID uuid.UUID `json:"recipient_membership_id"` + Subject string `json:"subject"` + Body string `json:"body"` + Status string `json:"status"` + SentAt time.Time `json:"sent_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type SlackEvent struct { EventID string `json:"event_id"` EventType string `json:"event_type"` @@ -74,13 +118,44 @@ type SlackEvent struct { CreatedAt time.Time `json:"created_at"` } +type SponsorVisibleHackathon struct { + SponsorRoleBindingID uuid.UUID `json:"sponsor_role_binding_id"` + HackathonID uuid.UUID `json:"hackathon_id"` + CreatedAt time.Time `json:"created_at"` +} + type Team struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - SlackChannelID sql.NullString `json:"slack_channel_id"` - TechStack []string `json:"tech_stack"` - Phase string `json:"phase"` - IsSos bool `json:"is_sos"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SlackChannelID sql.NullString `json:"slack_channel_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + HackathonID uuid.UUID `json:"hackathon_id"` + SlackWorkspaceID sql.NullString `json:"slack_workspace_id"` +} + +type User struct { + ID uuid.UUID `json:"id"` + AuthSubject string `json:"auth_subject"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + PasswordHash string `json:"password_hash"` + Status string `json:"status"` + EmailVerifiedAt sql.NullTime `json:"email_verified_at"` + LastLoginAt sql.NullTime `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserRoleBinding struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Role string `json:"role"` + ScopeType string `json:"scope_type"` + HackathonID uuid.NullUUID `json:"hackathon_id"` + GrantedAt time.Time `json:"granted_at"` + RevokedAt sql.NullTime `json:"revoked_at"` } diff --git a/apps/api/db/sqlcgen/progress.sql.go b/apps/api/db/sqlcgen/progress.sql.go index 8ab6329..24bb794 100644 --- a/apps/api/db/sqlcgen/progress.sql.go +++ b/apps/api/db/sqlcgen/progress.sql.go @@ -13,12 +13,20 @@ import ( ) const insertProgressLog = `-- name: InsertProgressLog :one -INSERT INTO progress_logs ( - team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_event_id -) VALUES ( - $1, $2, $3, $4, $5, $6, $7 -) -RETURNING id, team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_message_ts, slack_event_id, created_at +INSERT INTO + progress_logs ( + team_id, + slack_user_id, + phase, + status_text, + blockers, + is_sos, + slack_event_id + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7) +RETURNING + id, team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_message_ts, slack_event_id, created_at ` type InsertProgressLogParams struct { @@ -58,9 +66,13 @@ func (q *Queries) InsertProgressLog(ctx context.Context, arg InsertProgressLogPa } const latestProgressPerTeam = `-- name: LatestProgressPerTeam :many -SELECT DISTINCT ON (team_id) id, team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_message_ts, slack_event_id, created_at -FROM progress_logs -ORDER BY team_id, created_at DESC +SELECT DISTINCT + ON (team_id) id, team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_message_ts, slack_event_id, created_at +FROM + progress_logs +ORDER BY + team_id, + created_at DESC ` func (q *Queries) LatestProgressPerTeam(ctx context.Context) ([]ProgressLog, error) { @@ -98,20 +110,42 @@ func (q *Queries) LatestProgressPerTeam(ctx context.Context) ([]ProgressLog, err } const listProgressLogs = `-- name: ListProgressLogs :many -SELECT id, team_id, slack_user_id, phase, status_text, blockers, is_sos, slack_message_ts, slack_event_id, created_at FROM progress_logs -WHERE ($1::uuid IS NULL OR team_id = $1) -ORDER BY created_at DESC -LIMIT $2 OFFSET $3 +SELECT + progress_logs.id, progress_logs.team_id, progress_logs.slack_user_id, progress_logs.phase, progress_logs.status_text, progress_logs.blockers, progress_logs.is_sos, progress_logs.slack_message_ts, progress_logs.slack_event_id, progress_logs.created_at +FROM + progress_logs + LEFT JOIN teams ON teams.id = progress_logs.team_id +WHERE + ( + $1::uuid IS NULL + OR progress_logs.team_id = $1::uuid + ) + AND ( + $2::uuid IS NULL + OR teams.hackathon_id = $2::uuid + ) +ORDER BY + progress_logs.created_at DESC +LIMIT + $4::int +OFFSET + $3::int ` type ListProgressLogsParams struct { - TeamID uuid.NullUUID `json:"team_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + TeamID uuid.NullUUID `json:"team_id"` + HackathonID uuid.NullUUID `json:"hackathon_id"` + PageOffset int32 `json:"page_offset"` + PageLimit int32 `json:"page_limit"` } func (q *Queries) ListProgressLogs(ctx context.Context, arg ListProgressLogsParams) ([]ProgressLog, error) { - rows, err := q.db.QueryContext(ctx, listProgressLogs, arg.TeamID, arg.Limit, arg.Offset) + rows, err := q.db.QueryContext(ctx, listProgressLogs, + arg.TeamID, + arg.HackathonID, + arg.PageOffset, + arg.PageLimit, + ) if err != nil { return nil, err } @@ -145,7 +179,11 @@ func (q *Queries) ListProgressLogs(ctx context.Context, arg ListProgressLogsPara } const updateProgressSlackTS = `-- name: UpdateProgressSlackTS :exec -UPDATE progress_logs SET slack_message_ts = $2 WHERE id = $1 +UPDATE progress_logs +SET + slack_message_ts = $2 +WHERE + id = $1 ` type UpdateProgressSlackTSParams struct { diff --git a/apps/api/db/sqlcgen/questions.sql.go b/apps/api/db/sqlcgen/questions.sql.go index 3540ad0..869d14a 100644 --- a/apps/api/db/sqlcgen/questions.sql.go +++ b/apps/api/db/sqlcgen/questions.sql.go @@ -14,7 +14,12 @@ import ( ) const getQuestion = `-- name: GetQuestion :one -SELECT id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at FROM questions WHERE id = $1 +SELECT + id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at +FROM + questions +WHERE + id = $1 ` func (q *Queries) GetQuestion(ctx context.Context, id uuid.UUID) (Question, error) { @@ -44,7 +49,12 @@ func (q *Queries) GetQuestion(ctx context.Context, id uuid.UUID) (Question, erro } const getQuestionByEventID = `-- name: GetQuestionByEventID :one -SELECT id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at FROM questions WHERE slack_event_id = $1 +SELECT + id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at +FROM + questions +WHERE + slack_event_id = $1 ` func (q *Queries) GetQuestionByEventID(ctx context.Context, slackEventID sql.NullString) (Question, error) { @@ -74,13 +84,23 @@ func (q *Queries) GetQuestionByEventID(ctx context.Context, slackEventID sql.Nul } const insertQuestion = `-- name: InsertQuestion :one -INSERT INTO questions ( - team_id, slack_user_id, slack_thread_ts, slack_channel_id, - title, body, error_message, tried, status, slack_event_id -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9 -) -RETURNING id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at +INSERT INTO + questions ( + team_id, + slack_user_id, + slack_thread_ts, + slack_channel_id, + title, + body, + error_message, + tried, + status, + slack_event_id + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9) +RETURNING + id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at ` type InsertQuestionParams struct { @@ -132,20 +152,42 @@ func (q *Queries) InsertQuestion(ctx context.Context, arg InsertQuestionParams) } const listQuestions = `-- name: ListQuestions :many -SELECT id, team_id, slack_user_id, slack_thread_ts, slack_channel_id, title, body, error_message, tried, triage_result, category, confidence, status, escalated_to_mentor, mentor_channel_ts, slack_event_id, created_at, updated_at FROM questions -WHERE ($1::text IS NULL OR status = $1) -ORDER BY created_at DESC -LIMIT $2 OFFSET $3 +SELECT + questions.id, questions.team_id, questions.slack_user_id, questions.slack_thread_ts, questions.slack_channel_id, questions.title, questions.body, questions.error_message, questions.tried, questions.triage_result, questions.category, questions.confidence, questions.status, questions.escalated_to_mentor, questions.mentor_channel_ts, questions.slack_event_id, questions.created_at, questions.updated_at +FROM + questions + LEFT JOIN teams ON teams.id = questions.team_id +WHERE + ( + $1::text IS NULL + OR questions.status = $1::text + ) + AND ( + $2::uuid IS NULL + OR teams.hackathon_id = $2::uuid + ) +ORDER BY + questions.created_at DESC +LIMIT + $4::int +OFFSET + $3::int ` type ListQuestionsParams struct { - Status sql.NullString `json:"status"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Status sql.NullString `json:"status"` + HackathonID uuid.NullUUID `json:"hackathon_id"` + PageOffset int32 `json:"page_offset"` + PageLimit int32 `json:"page_limit"` } func (q *Queries) ListQuestions(ctx context.Context, arg ListQuestionsParams) ([]Question, error) { - rows, err := q.db.QueryContext(ctx, listQuestions, arg.Status, arg.Limit, arg.Offset) + rows, err := q.db.QueryContext(ctx, listQuestions, + arg.Status, + arg.HackathonID, + arg.PageOffset, + arg.PageLimit, + ) if err != nil { return nil, err } @@ -190,10 +232,11 @@ const updateQuestionEscalated = `-- name: UpdateQuestionEscalated :exec UPDATE questions SET escalated_to_mentor = TRUE, - mentor_channel_ts = $2, - status = 'escalated', - updated_at = NOW() -WHERE id = $1 + mentor_channel_ts = $2, + status = 'escalated', + updated_at = NOW() +WHERE + id = $1 ` type UpdateQuestionEscalatedParams struct { @@ -208,8 +251,11 @@ func (q *Queries) UpdateQuestionEscalated(ctx context.Context, arg UpdateQuestio const updateQuestionStatus = `-- name: UpdateQuestionStatus :exec UPDATE questions -SET status = $2, updated_at = NOW() -WHERE id = $1 +SET + status = $2, + updated_at = NOW() +WHERE + id = $1 ` type UpdateQuestionStatusParams struct { @@ -226,11 +272,12 @@ const updateQuestionTriage = `-- name: UpdateQuestionTriage :exec UPDATE questions SET triage_result = $2, - category = $3, - confidence = $4, - status = $5, - updated_at = NOW() -WHERE id = $1 + category = $3, + confidence = $4, + status = $5, + updated_at = NOW() +WHERE + id = $1 ` type UpdateQuestionTriageParams struct { diff --git a/apps/api/db/sqlcgen/teams.sql.go b/apps/api/db/sqlcgen/teams.sql.go index 5874bf4..a4d03ed 100644 --- a/apps/api/db/sqlcgen/teams.sql.go +++ b/apps/api/db/sqlcgen/teams.sql.go @@ -14,7 +14,12 @@ import ( ) const getTeam = `-- name: GetTeam :one -SELECT id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at FROM teams WHERE id = $1 +SELECT + id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at, hackathon_id, slack_workspace_id +FROM + teams +WHERE + id = $1 ` func (q *Queries) GetTeam(ctx context.Context, id uuid.UUID) (Team, error) { @@ -29,22 +34,37 @@ func (q *Queries) GetTeam(ctx context.Context, id uuid.UUID) (Team, error) { &i.IsSos, &i.CreatedAt, &i.UpdatedAt, + &i.HackathonID, + &i.SlackWorkspaceID, ) return i, err } const insertTeam = `-- name: InsertTeam :one -INSERT INTO teams (name, slack_channel_id, tech_stack, phase, is_sos) -VALUES ($1, $2, $3, $4, $5) -RETURNING id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at +INSERT INTO + teams ( + name, + slack_channel_id, + tech_stack, + phase, + is_sos, + hackathon_id, + slack_workspace_id + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7) +RETURNING + id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at, hackathon_id, slack_workspace_id ` type InsertTeamParams struct { - Name string `json:"name"` - SlackChannelID sql.NullString `json:"slack_channel_id"` - TechStack []string `json:"tech_stack"` - Phase string `json:"phase"` - IsSos bool `json:"is_sos"` + Name string `json:"name"` + SlackChannelID sql.NullString `json:"slack_channel_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` + HackathonID uuid.UUID `json:"hackathon_id"` + SlackWorkspaceID sql.NullString `json:"slack_workspace_id"` } func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) { @@ -54,6 +74,8 @@ func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, e pq.Array(arg.TechStack), arg.Phase, arg.IsSos, + arg.HackathonID, + arg.SlackWorkspaceID, ) var i Team err := row.Scan( @@ -65,16 +87,28 @@ func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, e &i.IsSos, &i.CreatedAt, &i.UpdatedAt, + &i.HackathonID, + &i.SlackWorkspaceID, ) return i, err } const listTeams = `-- name: ListTeams :many -SELECT id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at FROM teams ORDER BY name +SELECT + id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at, hackathon_id, slack_workspace_id +FROM + teams +WHERE + ( + $1::uuid IS NULL + OR hackathon_id = $1::uuid + ) +ORDER BY + name ` -func (q *Queries) ListTeams(ctx context.Context) ([]Team, error) { - rows, err := q.db.QueryContext(ctx, listTeams) +func (q *Queries) ListTeams(ctx context.Context, hackathonID uuid.NullUUID) ([]Team, error) { + rows, err := q.db.QueryContext(ctx, listTeams, hackathonID) if err != nil { return nil, err } @@ -91,6 +125,8 @@ func (q *Queries) ListTeams(ctx context.Context) ([]Team, error) { &i.IsSos, &i.CreatedAt, &i.UpdatedAt, + &i.HackathonID, + &i.SlackWorkspaceID, ); err != nil { return nil, err } @@ -106,7 +142,12 @@ func (q *Queries) ListTeams(ctx context.Context) ([]Team, error) { } const updateTeamPhase = `-- name: UpdateTeamPhase :exec -UPDATE teams SET phase = $2, updated_at = NOW() WHERE id = $1 +UPDATE teams +SET + phase = $2, + updated_at = NOW() +WHERE + id = $1 ` type UpdateTeamPhaseParams struct { @@ -120,7 +161,12 @@ func (q *Queries) UpdateTeamPhase(ctx context.Context, arg UpdateTeamPhaseParams } const updateTeamSOS = `-- name: UpdateTeamSOS :exec -UPDATE teams SET is_sos = $2, updated_at = NOW() WHERE id = $1 +UPDATE teams +SET + is_sos = $2, + updated_at = NOW() +WHERE + id = $1 ` type UpdateTeamSOSParams struct { @@ -132,3 +178,55 @@ func (q *Queries) UpdateTeamSOS(ctx context.Context, arg UpdateTeamSOSParams) er _, err := q.db.ExecContext(ctx, updateTeamSOS, arg.ID, arg.IsSos) return err } + +const updateTeamSetup = `-- name: UpdateTeamSetup :one +UPDATE teams +SET + name = $2, + slack_channel_id = $3, + tech_stack = $4, + phase = $5, + is_sos = $6, + slack_workspace_id = $7, + updated_at = NOW() +WHERE + id = $1 +RETURNING + id, name, slack_channel_id, tech_stack, phase, is_sos, created_at, updated_at, hackathon_id, slack_workspace_id +` + +type UpdateTeamSetupParams struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SlackChannelID sql.NullString `json:"slack_channel_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` + SlackWorkspaceID sql.NullString `json:"slack_workspace_id"` +} + +func (q *Queries) UpdateTeamSetup(ctx context.Context, arg UpdateTeamSetupParams) (Team, error) { + row := q.db.QueryRowContext(ctx, updateTeamSetup, + arg.ID, + arg.Name, + arg.SlackChannelID, + pq.Array(arg.TechStack), + arg.Phase, + arg.IsSos, + arg.SlackWorkspaceID, + ) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.SlackChannelID, + pq.Array(&i.TechStack), + &i.Phase, + &i.IsSos, + &i.CreatedAt, + &i.UpdatedAt, + &i.HackathonID, + &i.SlackWorkspaceID, + ) + return i, err +} diff --git a/apps/api/go.mod b/apps/api/go.mod index 14f967a..d621dff 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,8 +1,6 @@ module github.com/Asheze1127/HackHub/apps/api -go 1.24.0 - -toolchain go1.24.5 +go 1.25 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -39,6 +37,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -46,6 +45,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/slack-go/slack v0.21.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index c2da3bb..853f378 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -62,6 +62,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -86,6 +88,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/slack-go/slack v0.21.0 h1:TAGnZYFp79LAG/oqFzYhFJ9LwEwXJ93heCkPvwjxc7o= +github.com/slack-go/slack v0.21.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/sqlc-dev/pqtype v0.3.0 h1:b09TewZ3cSnO5+M1Kqq05y0+OjqIptxELaSayg7bmqk= github.com/sqlc-dev/pqtype v0.3.0/go.mod h1:oyUjp5981ctiL9UYvj1bVvCKi8OXkCa0u645hce7CAs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/apps/api/internal/api/handler.go b/apps/api/internal/api/handler.go index 49ad09f..fc795b6 100644 --- a/apps/api/internal/api/handler.go +++ b/apps/api/internal/api/handler.go @@ -2,13 +2,16 @@ package api import ( "database/sql" + "errors" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/Asheze1127/HackHub/apps/api/db/sqlcgen" + "github.com/Asheze1127/HackHub/apps/api/internal/authz" ) const ( @@ -16,30 +19,170 @@ const ( maxLimit = 100 ) +func parseUUIDQuery(c *gin.Context, key string) (uuid.NullUUID, bool) { + raw := c.Query(key) + if raw == "" { + return uuid.NullUUID{}, true + } + parsed, err := uuid.Parse(raw) + if err != nil { + return uuid.NullUUID{}, false + } + return uuid.NullUUID{UUID: parsed, Valid: true}, true +} + +func validateQuestionStatus(status string) error { + switch status { + case "pending", "answered", "in_progress", "escalated", "resolved", "closed": + return nil + default: + return errors.New("invalid question status") + } +} + +func questionInHackathon(q *sqlcgen.Queries, c *gin.Context, question sqlcgen.Question, hackathonID uuid.NullUUID) (bool, error) { + if !hackathonID.Valid { + return true, nil + } + if !question.TeamID.Valid { + return false, nil + } + team, err := q.GetTeam(c.Request.Context(), question.TeamID.UUID) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return team.HackathonID == hackathonID.UUID, nil +} + +func parseViewer(c *gin.Context, secret string) (authz.Viewer, error) { + return authz.ParseViewer( + c.GetHeader(authz.HeaderViewerRole), + c.GetHeader(authz.HeaderViewerHackathonID), + c.GetHeader(authz.HeaderViewerSignature), + secret, + ) +} + +// HandleListHackathons handles GET /api/hackathons. +func HandleListHackathons(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { + q := sqlcgen.New(db) + return func(c *gin.Context) { + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + hackathons, err := q.ListHackathons(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + filtered := hackathons + if viewer.Trusted && viewer.HackathonID.Valid { + filtered = make([]sqlcgen.Hackathon, 0, 1) + for _, hackathon := range hackathons { + if hackathon.ID == viewer.HackathonID.UUID { + filtered = append(filtered, hackathon) + } + } + } + resp := make([]HackathonResponse, len(filtered)) + for i, hackathon := range filtered { + resp[i] = toHackathonResponse(hackathon) + } + c.JSON(http.StatusOK, resp) + } +} + +// HandleCreateHackathon handles POST /api/hackathons. +func HandleCreateHackathon(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { + q := sqlcgen.New(db) + return func(c *gin.Context) { + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if !viewer.Trusted || !authz.CanManageHackathons(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot create hackathons"}) + return + } + + var req createHackathonRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + slug := strings.TrimSpace(req.Slug) + name := strings.TrimSpace(req.Name) + if slug == "" || name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "slug and name are required"}) + return + } + + hackathon, err := q.InsertHackathon(c.Request.Context(), sqlcgen.InsertHackathonParams{ + Slug: slug, + Name: name, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + c.JSON(http.StatusCreated, toHackathonResponse(hackathon)) + } +} + // HandleListQuestions handles GET /api/questions. -// Optional query params: status (string), limit (int, default 20), offset (int, default 0). -func HandleListQuestions(db *sql.DB) gin.HandlerFunc { +// Optional query params: status (string), hackathon_id (uuid), limit (int, default 20), offset (int, default 0). +func HandleListQuestions(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { q := sqlcgen.New(db) return func(c *gin.Context) { status := c.Query("status") + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if viewer.Trusted && !authz.CanReadOperationalData(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot access questions"}) + return + } + hackathonID, ok := parseUUIDQuery(c, "hackathon_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonID, err = authz.EffectiveHackathonScope(viewer, hackathonID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } limit := parseIntQuery(c, "limit", defaultLimit, maxLimit) offset := parseIntQuery(c, "offset", 0, -1) questions, err := q.ListQuestions(c.Request.Context(), sqlcgen.ListQuestionsParams{ - Status: sql.NullString{String: status, Valid: status != ""}, - Limit: int32(limit), - Offset: int32(offset), + Status: sql.NullString{String: status, Valid: status != ""}, + HackathonID: hackathonID, + PageLimit: int32(limit), + PageOffset: int32(offset), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, questions) + resp := make([]QuestionResponse, len(questions)) + for i, question := range questions { + resp[i] = toQuestionResponse(question) + } + c.JSON(http.StatusOK, resp) } } // HandleGetQuestion handles GET /api/questions/:id. -func HandleGetQuestion(db *sql.DB) gin.HandlerFunc { +func HandleGetQuestion(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { q := sqlcgen.New(db) return func(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) @@ -47,6 +190,15 @@ func HandleGetQuestion(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if viewer.Trusted && !authz.CanReadOperationalData(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot access question detail"}) + return + } question, err := q.GetQuestion(c.Request.Context(), id) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) @@ -56,51 +208,355 @@ func HandleGetQuestion(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, question) + hackathonID, ok := parseUUIDQuery(c, "hackathon_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonID, err = authz.EffectiveHackathonScope(viewer, hackathonID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + inScope, err := questionInHackathon(q, c, question, hackathonID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if !inScope { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, toQuestionResponse(question)) + } +} + +type updateQuestionStatusRequest struct { + Status string `json:"status"` +} + +type createHackathonRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` +} + +type createTeamRequest struct { + Name string `json:"name"` + HackathonID string `json:"hackathon_id"` + SlackChannelID *string `json:"slack_channel_id"` + SlackWorkspaceID *string `json:"slack_workspace_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` +} + +type updateTeamRequest struct { + Name string `json:"name"` + SlackChannelID *string `json:"slack_channel_id"` + SlackWorkspaceID *string `json:"slack_workspace_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` +} + +func normalizeOptionalString(value *string) sql.NullString { + if value == nil { + return sql.NullString{} + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return sql.NullString{} + } + return sql.NullString{String: trimmed, Valid: true} +} + +// HandleUpdateQuestionStatus handles PATCH /api/questions/:id/status. +func HandleUpdateQuestionStatus(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { + q := sqlcgen.New(db) + return func(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if !viewer.Trusted || !authz.CanUpdateQuestionStatus(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot update question status"}) + return + } + + var req updateQuestionStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if err := validateQuestionStatus(req.Status); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + hackathonID, ok := parseUUIDQuery(c, "hackathon_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonID, err = authz.EffectiveHackathonScope(viewer, hackathonID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + existing, err := q.GetQuestion(c.Request.Context(), id) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + inScope, err := questionInHackathon(q, c, existing, hackathonID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if !inScope { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + if err := q.UpdateQuestionStatus(c.Request.Context(), sqlcgen.UpdateQuestionStatusParams{ + ID: existing.ID, + Status: req.Status, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + + updated, err := q.GetQuestion(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + c.JSON(http.StatusOK, toQuestionResponse(updated)) } } // HandleListProgress handles GET /api/progress. -// Optional query params: team_id (uuid), limit, offset. -func HandleListProgress(db *sql.DB) gin.HandlerFunc { +// Optional query params: team_id (uuid), hackathon_id (uuid), limit, offset. +func HandleListProgress(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { q := sqlcgen.New(db) return func(c *gin.Context) { + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if viewer.Trusted && !authz.CanReadOperationalData(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot access progress"}) + return + } limit := parseIntQuery(c, "limit", defaultLimit, maxLimit) offset := parseIntQuery(c, "offset", 0, -1) - var teamID uuid.NullUUID - if raw := c.Query("team_id"); raw != "" { - parsed, err := uuid.Parse(raw) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid team_id"}) - return - } - teamID = uuid.NullUUID{UUID: parsed, Valid: true} + teamID, ok := parseUUIDQuery(c, "team_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid team_id"}) + return + } + hackathonID, ok := parseUUIDQuery(c, "hackathon_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonID, err = authz.EffectiveHackathonScope(viewer, hackathonID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return } logs, err := q.ListProgressLogs(c.Request.Context(), sqlcgen.ListProgressLogsParams{ - TeamID: teamID, - Limit: int32(limit), - Offset: int32(offset), + TeamID: teamID, + HackathonID: hackathonID, + PageLimit: int32(limit), + PageOffset: int32(offset), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, logs) + resp := make([]ProgressLogResponse, len(logs)) + for i, log := range logs { + resp[i] = toProgressLogResponse(log) + } + c.JSON(http.StatusOK, resp) } } // HandleListTeams handles GET /api/teams. -func HandleListTeams(db *sql.DB) gin.HandlerFunc { +// Optional query params: hackathon_id (uuid). +func HandleListTeams(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { q := sqlcgen.New(db) return func(c *gin.Context) { - teams, err := q.ListTeams(c.Request.Context()) + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if viewer.Trusted && !authz.CanReadOperationalData(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot access teams"}) + return + } + hackathonID, ok := parseUUIDQuery(c, "hackathon_id") + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonID, err = authz.EffectiveHackathonScope(viewer, hackathonID) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + teams, err := q.ListTeams(c.Request.Context(), hackathonID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + resp := make([]TeamResponse, len(teams)) + for i, team := range teams { + resp[i] = toTeamResponse(team) + } + c.JSON(http.StatusOK, resp) + } +} + +// HandleCreateTeam handles POST /api/teams. +func HandleCreateTeam(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { + q := sqlcgen.New(db) + return func(c *gin.Context) { + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if !viewer.Trusted || !authz.CanManageTeams(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot create teams"}) + return + } + + var req createTeamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + phase := strings.TrimSpace(req.Phase) + if phase == "" { + phase = "planning" + } + + rawHackathonID := strings.TrimSpace(req.HackathonID) + if rawHackathonID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "hackathon_id is required"}) + return + } + parsedHackathonID, err := uuid.Parse(rawHackathonID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid hackathon_id"}) + return + } + hackathonScope, err := authz.EffectiveHackathonScope(viewer, uuid.NullUUID{UUID: parsedHackathonID, Valid: true}) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + team, err := q.InsertTeam(c.Request.Context(), sqlcgen.InsertTeamParams{ + Name: name, + SlackChannelID: normalizeOptionalString(req.SlackChannelID), + TechStack: req.TechStack, + Phase: phase, + IsSos: req.IsSos, + HackathonID: hackathonScope.UUID, + SlackWorkspaceID: normalizeOptionalString(req.SlackWorkspaceID), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + c.JSON(http.StatusCreated, toTeamResponse(team)) + } +} + +// HandleUpdateTeam handles PATCH /api/teams/:id. +func HandleUpdateTeam(db *sql.DB, internalAuthSecret string) gin.HandlerFunc { + q := sqlcgen.New(db) + return func(c *gin.Context) { + viewer, err := parseViewer(c, internalAuthSecret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid viewer"}) + return + } + if !viewer.Trusted || !authz.CanManageTeams(viewer.Role) { + c.JSON(http.StatusForbidden, gin.H{"error": "viewer cannot update teams"}) + return + } + + teamID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + existing, err := q.GetTeam(c.Request.Context(), teamID) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if _, err := authz.EffectiveHackathonScope(viewer, uuid.NullUUID{UUID: existing.HackathonID, Valid: true}); err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + var req updateTeamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + phase := strings.TrimSpace(req.Phase) + if phase == "" { + phase = existing.Phase + } + + team, err := q.UpdateTeamSetup(c.Request.Context(), sqlcgen.UpdateTeamSetupParams{ + ID: existing.ID, + Name: name, + SlackChannelID: normalizeOptionalString(req.SlackChannelID), + TechStack: req.TechStack, + Phase: phase, + IsSos: req.IsSos, + SlackWorkspaceID: normalizeOptionalString(req.SlackWorkspaceID), + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, teams) + c.JSON(http.StatusOK, toTeamResponse(team)) } } diff --git a/apps/api/internal/api/response.go b/apps/api/internal/api/response.go new file mode 100644 index 0000000..8f9f4aa --- /dev/null +++ b/apps/api/internal/api/response.go @@ -0,0 +1,168 @@ +package api + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + + "github.com/Asheze1127/HackHub/apps/api/db/sqlcgen" +) + +type QuestionResponse struct { + ID uuid.UUID `json:"id"` + TeamID *string `json:"team_id"` + SlackUserID string `json:"slack_user_id"` + SlackThreadTs *string `json:"slack_thread_ts"` + SlackChannelID string `json:"slack_channel_id"` + Title string `json:"title"` + Body *string `json:"body"` + ErrorMessage *string `json:"error_message"` + Tried *string `json:"tried"` + TriageResult *json.RawMessage `json:"triage_result"` + Category *string `json:"category"` + Confidence *float64 `json:"confidence"` + Status string `json:"status"` + EscalatedToMentor bool `json:"escalated_to_mentor"` + MentorChannelTs *string `json:"mentor_channel_ts"` + SlackEventID *string `json:"slack_event_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type HackathonResponse struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProgressLogResponse struct { + ID uuid.UUID `json:"id"` + TeamID *string `json:"team_id"` + SlackUserID string `json:"slack_user_id"` + Phase string `json:"phase"` + StatusText *string `json:"status_text"` + Blockers *string `json:"blockers"` + IsSos bool `json:"is_sos"` + SlackMessageTs *string `json:"slack_message_ts"` + SlackEventID *string `json:"slack_event_id"` + CreatedAt time.Time `json:"created_at"` +} + +type TeamResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SlackChannelID *string `json:"slack_channel_id"` + SlackWorkspaceID *string `json:"slack_workspace_id"` + HackathonID string `json:"hackathon_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toHackathonResponse(h sqlcgen.Hackathon) HackathonResponse { + return HackathonResponse{ + ID: h.ID, + Slug: h.Slug, + Name: h.Name, + CreatedAt: h.CreatedAt, + UpdatedAt: h.UpdatedAt, + } +} + +func toQuestionResponse(q sqlcgen.Question) QuestionResponse { + r := QuestionResponse{ + ID: q.ID, + SlackUserID: q.SlackUserID, + SlackChannelID: q.SlackChannelID, + Title: q.Title, + Status: q.Status, + EscalatedToMentor: q.EscalatedToMentor, + CreatedAt: q.CreatedAt, + UpdatedAt: q.UpdatedAt, + } + if q.TeamID.Valid { + s := q.TeamID.UUID.String() + r.TeamID = &s + } + if q.SlackThreadTs.Valid { + r.SlackThreadTs = &q.SlackThreadTs.String + } + if q.Body.Valid { + r.Body = &q.Body.String + } + if q.ErrorMessage.Valid { + r.ErrorMessage = &q.ErrorMessage.String + } + if q.Tried.Valid { + r.Tried = &q.Tried.String + } + if q.TriageResult.Valid { + raw := json.RawMessage(q.TriageResult.RawMessage) + r.TriageResult = &raw + } + if q.Category.Valid { + r.Category = &q.Category.String + } + if q.Confidence.Valid { + r.Confidence = &q.Confidence.Float64 + } + if q.MentorChannelTs.Valid { + r.MentorChannelTs = &q.MentorChannelTs.String + } + if q.SlackEventID.Valid { + r.SlackEventID = &q.SlackEventID.String + } + return r +} + +func toProgressLogResponse(p sqlcgen.ProgressLog) ProgressLogResponse { + r := ProgressLogResponse{ + ID: p.ID, + SlackUserID: p.SlackUserID, + Phase: p.Phase, + IsSos: p.IsSos, + CreatedAt: p.CreatedAt, + } + if p.TeamID.Valid { + s := p.TeamID.UUID.String() + r.TeamID = &s + } + if p.StatusText.Valid { + r.StatusText = &p.StatusText.String + } + if p.Blockers.Valid { + r.Blockers = &p.Blockers.String + } + if p.SlackMessageTs.Valid { + r.SlackMessageTs = &p.SlackMessageTs.String + } + if p.SlackEventID.Valid { + r.SlackEventID = &p.SlackEventID.String + } + return r +} + +func toTeamResponse(t sqlcgen.Team) TeamResponse { + r := TeamResponse{ + ID: t.ID, + Name: t.Name, + HackathonID: t.HackathonID.String(), + TechStack: t.TechStack, + Phase: t.Phase, + IsSos: t.IsSos, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } + if t.SlackChannelID.Valid { + r.SlackChannelID = &t.SlackChannelID.String + } + if t.SlackWorkspaceID.Valid { + r.SlackWorkspaceID = &t.SlackWorkspaceID.String + } + return r +} diff --git a/apps/api/internal/authz/viewer.go b/apps/api/internal/authz/viewer.go new file mode 100644 index 0000000..15fd6c8 --- /dev/null +++ b/apps/api/internal/authz/viewer.go @@ -0,0 +1,134 @@ +package authz + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + + "github.com/google/uuid" +) + +const ( + HeaderViewerRole = "X-HackHub-Viewer-Role" + HeaderViewerHackathonID = "X-HackHub-Viewer-Hackathon-Id" + HeaderViewerSignature = "X-HackHub-Viewer-Signature" +) + +var ( + ErrViewerSignatureMissing = errors.New("missing viewer signature") + ErrViewerSignatureInvalid = errors.New("invalid viewer signature") + ErrViewerRoleInvalid = errors.New("invalid viewer role") + ErrViewerScopeMismatch = errors.New("requested hackathon is outside viewer scope") + ErrViewerScopeRequired = errors.New("viewer hackathon scope required") +) + +type Viewer struct { + Role string + HackathonID uuid.NullUUID + Trusted bool +} + +func SignViewer(role, hackathonID, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(role)) + mac.Write([]byte("\n")) + mac.Write([]byte(hackathonID)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func ParseViewer(role, hackathonID, signature, secret string) (Viewer, error) { + if role == "" && hackathonID == "" && signature == "" { + return Viewer{}, nil + } + if !IsAllowedRole(role) { + return Viewer{}, ErrViewerRoleInvalid + } + if signature == "" { + return Viewer{}, ErrViewerSignatureMissing + } + + expected := SignViewer(role, hackathonID, secret) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return Viewer{}, ErrViewerSignatureInvalid + } + + viewer := Viewer{ + Role: role, + Trusted: true, + } + if hackathonID == "" { + if RoleRequiresHackathon(role) { + return Viewer{}, ErrViewerScopeRequired + } + return viewer, nil + } + + parsed, err := uuid.Parse(hackathonID) + if err != nil { + return Viewer{}, fmt.Errorf("invalid hackathon id: %w", err) + } + viewer.HackathonID = uuid.NullUUID{UUID: parsed, Valid: true} + return viewer, nil +} + +func IsAllowedRole(role string) bool { + switch role { + case "platform_admin", "tenant_admin", "hackathon_organizer", "mentor", "sponsor", "judge", "hacker": + return true + default: + return false + } +} + +func RoleRequiresHackathon(role string) bool { + return role == "hackathon_organizer" || role == "mentor" || role == "sponsor" || role == "judge" || role == "hacker" +} + +func CanReadOperationalData(role string) bool { + switch role { + case "platform_admin", "tenant_admin", "hackathon_organizer", "mentor": + return true + default: + return false + } +} + +func CanUpdateQuestionStatus(role string) bool { + return CanReadOperationalData(role) +} + +func CanManageHackathons(role string) bool { + switch role { + case "platform_admin", "tenant_admin": + return true + default: + return false + } +} + +func CanManageTeams(role string) bool { + switch role { + case "platform_admin", "tenant_admin", "hackathon_organizer": + return true + default: + return false + } +} + +func EffectiveHackathonScope(viewer Viewer, requested uuid.NullUUID) (uuid.NullUUID, error) { + if !viewer.Trusted { + return requested, nil + } + if !RoleRequiresHackathon(viewer.Role) { + return requested, nil + } + if !viewer.HackathonID.Valid { + return uuid.NullUUID{}, ErrViewerScopeRequired + } + if requested.Valid && requested.UUID != viewer.HackathonID.UUID { + return uuid.NullUUID{}, ErrViewerScopeMismatch + } + return viewer.HackathonID, nil +} diff --git a/apps/api/internal/authz/viewer_test.go b/apps/api/internal/authz/viewer_test.go new file mode 100644 index 0000000..2b5ff63 --- /dev/null +++ b/apps/api/internal/authz/viewer_test.go @@ -0,0 +1,72 @@ +package authz + +import ( + "testing" + + "github.com/google/uuid" +) + +func TestParseViewerRoundTrip(t *testing.T) { + hackathonID := uuid.NewString() + signature := SignViewer("mentor", hackathonID, "secret") + + viewer, err := ParseViewer("mentor", hackathonID, signature, "secret") + if err != nil { + t.Fatalf("ParseViewer returned error: %v", err) + } + if !viewer.Trusted { + t.Fatalf("viewer should be trusted") + } + if viewer.Role != "mentor" { + t.Fatalf("unexpected role: %s", viewer.Role) + } + if !viewer.HackathonID.Valid || viewer.HackathonID.UUID.String() != hackathonID { + t.Fatalf("unexpected hackathon scope: %+v", viewer.HackathonID) + } +} + +func TestParseViewerRejectsInvalidSignature(t *testing.T) { + if _, err := ParseViewer("mentor", uuid.NewString(), "bad", "secret"); err != ErrViewerSignatureInvalid { + t.Fatalf("expected ErrViewerSignatureInvalid, got %v", err) + } +} + +func TestEffectiveHackathonScope(t *testing.T) { + requestedID := uuid.New() + otherID := uuid.New() + viewer := Viewer{ + Role: "mentor", + Trusted: true, + HackathonID: uuid.NullUUID{UUID: requestedID, Valid: true}, + } + + scoped, err := EffectiveHackathonScope(viewer, uuid.NullUUID{}) + if err != nil { + t.Fatalf("EffectiveHackathonScope returned error: %v", err) + } + if !scoped.Valid || scoped.UUID != requestedID { + t.Fatalf("unexpected scoped hackathon: %+v", scoped) + } + + if _, err := EffectiveHackathonScope(viewer, uuid.NullUUID{UUID: otherID, Valid: true}); err != ErrViewerScopeMismatch { + t.Fatalf("expected ErrViewerScopeMismatch, got %v", err) + } +} + +func TestJudgeAndHackerRoles(t *testing.T) { + testCases := []struct { + role string + }{ + {role: "judge"}, + {role: "hacker"}, + } + + for _, tc := range testCases { + if !IsAllowedRole(tc.role) { + t.Fatalf("role should be allowed: %s", tc.role) + } + if !RoleRequiresHackathon(tc.role) { + t.Fatalf("role should require hackathon scope: %s", tc.role) + } + } +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 93209ad..813604c 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -6,18 +6,20 @@ import ( ) type Config struct { - Port string - Env string - DatabaseURL string - SlackBotToken string - SlackSigningSecret string - SlackMentorChannel string + Port string + Env string + DatabaseURL string + InternalAuthSecret string + SlackBotToken string + SlackAppToken string + SlackSigningSecret string + SlackMentorChannel string SlackProgressChannel string - AWSRegion string - AWSEndpointURL string - SQSQuestionNewURL string - SQSFollowupURL string - SQSProgressURL string + AWSRegion string + AWSEndpointURL string + SQSQuestionNewURL string + SQSFollowupURL string + SQSProgressURL string } func Load() (*Config, error) { @@ -25,11 +27,13 @@ func Load() (*Config, error) { Port: getEnv("PORT", "8080"), Env: getEnv("ENV", "development"), DatabaseURL: os.Getenv("DATABASE_URL"), + InternalAuthSecret: getEnv("HACKHUB_INTERNAL_AUTH_SECRET", "dev-internal-auth-secret"), SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), + SlackAppToken: os.Getenv("SLACK_APP_TOKEN"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackMentorChannel: os.Getenv("SLACK_MENTOR_CHANNEL_ID"), SlackProgressChannel: os.Getenv("SLACK_PROGRESS_CHANNEL_ID"), - AWSRegion: getEnv("AWS_REGION", "ap-northeast-1"), + AWSRegion: getEnv("AWS_REGION", "us-east-1"), AWSEndpointURL: os.Getenv("AWS_ENDPOINT_URL"), SQSQuestionNewURL: os.Getenv("SQS_QUESTION_NEW_URL"), SQSFollowupURL: os.Getenv("SQS_QUESTION_FOLLOWUP_URL"), @@ -38,8 +42,11 @@ func Load() (*Config, error) { if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") } - if cfg.SlackSigningSecret == "" { - return nil, fmt.Errorf("SLACK_SIGNING_SECRET is required") + if cfg.SlackBotToken == "" { + return nil, fmt.Errorf("SLACK_BOT_TOKEN is required") + } + if cfg.SlackAppToken == "" { + return nil, fmt.Errorf("SLACK_APP_TOKEN is required (xapp-... Socket Mode token)") } return cfg, nil } @@ -54,4 +61,3 @@ func getEnv(key, fallback string) string { } return fallback } - diff --git a/apps/api/internal/slack/handler.go b/apps/api/internal/slack/handler.go index e97cfab..5ad15b0 100644 --- a/apps/api/internal/slack/handler.go +++ b/apps/api/internal/slack/handler.go @@ -18,7 +18,7 @@ import ( // HandleSlashCommand handles POST /slack/commands. // Slack sends application/x-www-form-urlencoded with fields: -// command, trigger_id, user_id, team_id, channel_id, text. +// command, trigger_id, user_id, team_id (workspace id), channel_id, text. func HandleSlashCommand(cfg *config.Config, db *sql.DB) gin.HandlerFunc { return func(c *gin.Context) { // Read raw body first so we can verify the signature. @@ -44,6 +44,7 @@ func HandleSlashCommand(cfg *config.Config, db *sql.DB) gin.HandlerFunc { command := values.Get("command") triggerID := values.Get("trigger_id") channelID := values.Get("channel_id") + workspaceID := values.Get("team_id") if triggerID == "" { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "missing trigger_id"}) @@ -55,7 +56,16 @@ func HandleSlashCommand(cfg *config.Config, db *sql.DB) gin.HandlerFunc { // Do NOT add any DB writes before this call. switch command { case "/question": - if err := OpenQuestionModal(cfg.SlackBotToken, triggerID, channelID); err != nil { + botToken, tokenErr := resolveBotTokenForWorkspace(c.Request.Context(), cfg, db, workspaceID) + if tokenErr != nil { + log.Printf("[slack] resolve question workspace token failed: %v", tokenErr) + c.JSON(http.StatusOK, gin.H{ + "response_type": "ephemeral", + "text": "ワークスペース設定の読み込みに失敗しました。しばらく待ってから再試行してください。", + }) + return + } + if err := OpenQuestionModal(botToken, triggerID, channelID); err != nil { log.Printf("[slack] open question modal failed: %v", err) c.JSON(http.StatusOK, gin.H{ "response_type": "ephemeral", @@ -64,7 +74,16 @@ func HandleSlashCommand(cfg *config.Config, db *sql.DB) gin.HandlerFunc { return } case "/progress": - if err := OpenProgressModal(cfg.SlackBotToken, triggerID, channelID); err != nil { + botToken, tokenErr := resolveBotTokenForWorkspace(c.Request.Context(), cfg, db, workspaceID) + if tokenErr != nil { + log.Printf("[slack] resolve progress workspace token failed: %v", tokenErr) + c.JSON(http.StatusOK, gin.H{ + "response_type": "ephemeral", + "text": "ワークスペース設定の読み込みに失敗しました。しばらく待ってから再試行してください。", + }) + return + } + if err := OpenProgressModal(botToken, triggerID, channelID); err != nil { log.Printf("[slack] open progress modal failed: %v", err) c.JSON(http.StatusOK, gin.H{ "response_type": "ephemeral", @@ -166,13 +185,13 @@ func HandleInteraction(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender) gin func handleQuestionSubmission(ctx context.Context, sqsClient sqs.Sender, p *interactionPayload) error { vals := p.View.State.Values msg := questionNewMessage{ - UserID: p.User.ID, - TeamID: p.Team.ID, - ChannelID: p.View.PrivateMetadata, - Title: strVal(vals, "title_block", "title"), - Body: strVal(vals, "body_block", "body"), - ErrorMessage: strVal(vals, "error_block", "error_message"), - Tried: strVal(vals, "tried_block", "tried"), + UserID: p.User.ID, + SlackWorkspaceID: p.Team.ID, + ChannelID: p.View.PrivateMetadata, + Title: strVal(vals, "title_block", "title"), + Body: strVal(vals, "body_block", "body"), + ErrorMessage: strVal(vals, "error_block", "error_message"), + Tried: strVal(vals, "tried_block", "tried"), } body, err := json.Marshal(msg) if err != nil { @@ -186,13 +205,13 @@ func handleProgressSubmission(ctx context.Context, sqsClient sqs.Sender, p *inte vals := p.View.State.Values sosChecked := len(vals["sos_block"]["sos_flag"].SelectedOptions) > 0 msg := progressMessage{ - UserID: p.User.ID, - TeamID: p.Team.ID, - ChannelID: p.View.PrivateMetadata, - Phase: selectedVal(vals, "phase_block", "phase"), - CurrentStatus: strVal(vals, "status_block", "current_status"), - Blockers: strVal(vals, "blockers_block", "blockers"), - SOSFlag: sosChecked, + UserID: p.User.ID, + SlackWorkspaceID: p.Team.ID, + ChannelID: p.View.PrivateMetadata, + Phase: selectedVal(vals, "phase_block", "phase"), + CurrentStatus: strVal(vals, "status_block", "current_status"), + Blockers: strVal(vals, "blockers_block", "blockers"), + SOSFlag: sosChecked, } body, err := json.Marshal(msg) if err != nil { @@ -270,9 +289,9 @@ type viewState struct { type stateValues map[string]map[string]elementState type elementState struct { - Type string `json:"type"` - Value string `json:"value"` - SelectedOption *selectOption `json:"selected_option"` + Type string `json:"type"` + Value string `json:"value"` + SelectedOption *selectOption `json:"selected_option"` SelectedOptions []selectOption `json:"selected_options"` } @@ -286,21 +305,21 @@ type selectOption struct { // --- SQS message types --- type questionNewMessage struct { - UserID string `json:"user_id"` - TeamID string `json:"team_id"` - ChannelID string `json:"channel_id"` - Title string `json:"title"` - Body string `json:"body,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - Tried string `json:"tried,omitempty"` + UserID string `json:"user_id"` + SlackWorkspaceID string `json:"slack_workspace_id"` + ChannelID string `json:"channel_id"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Tried string `json:"tried,omitempty"` } type progressMessage struct { - UserID string `json:"user_id"` - TeamID string `json:"team_id"` - ChannelID string `json:"channel_id"` - Phase string `json:"phase"` - CurrentStatus string `json:"current_status,omitempty"` - Blockers string `json:"blockers,omitempty"` - SOSFlag bool `json:"sos_flag"` + UserID string `json:"user_id"` + SlackWorkspaceID string `json:"slack_workspace_id"` + ChannelID string `json:"channel_id"` + Phase string `json:"phase"` + CurrentStatus string `json:"current_status,omitempty"` + Blockers string `json:"blockers,omitempty"` + SOSFlag bool `json:"sos_flag"` } diff --git a/apps/api/internal/slack/modal.go b/apps/api/internal/slack/modal.go index 6b4b01c..fd04ca7 100644 --- a/apps/api/internal/slack/modal.go +++ b/apps/api/internal/slack/modal.go @@ -85,6 +85,23 @@ func OpenProgressModal(botToken, triggerID, channelID string) error { return callViewsOpen(botToken, triggerID, modal) } +// OpenMentorReplyModal opens the mentor reply modal via views.open. +// metadata is stored in private_metadata (format: channelId|threadTs|userId). +func OpenMentorReplyModal(botToken, triggerID, metadata string) error { + modal := map[string]any{ + "type": "modal", + "callback_id": "mentor_reply_modal", + "private_metadata": metadata, + "title": plainText("回答を入力"), + "submit": plainText("送信(匿名)"), + "close": plainText("キャンセル"), + "blocks": []map[string]any{ + textareaBlock("reply_block", "reply", "回答内容", "参加者への回答を入力してください", false), + }, + } + return callViewsOpen(botToken, triggerID, modal) +} + func callViewsOpen(botToken, triggerID string, view map[string]any) error { payload := map[string]any{ "trigger_id": triggerID, diff --git a/apps/api/internal/slack/socketmode.go b/apps/api/internal/slack/socketmode.go new file mode 100644 index 0000000..286f378 --- /dev/null +++ b/apps/api/internal/slack/socketmode.go @@ -0,0 +1,545 @@ +package slack + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + goslack "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + + "github.com/Asheze1127/HackHub/apps/api/internal/config" + "github.com/Asheze1127/HackHub/apps/api/internal/sqs" +) + +// RunSocketMode connects to Slack via WebSocket and handles slash commands +// and view submissions. It blocks until the connection is closed. +func RunSocketMode(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender) { + api := goslack.New( + cfg.SlackBotToken, + goslack.OptionAppLevelToken(cfg.SlackAppToken), + ) + client := socketmode.New(api, + socketmode.OptionLog(log.New(log.Writer(), "[socketmode] ", log.LstdFlags)), + ) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(goslack.SlashCommand) + if !ok { + client.Ack(*evt.Request) + continue + } + onSlashCommand(cfg, db, client, &evt, &cmd) + + case socketmode.EventTypeInteractive: + callback, ok := evt.Data.(goslack.InteractionCallback) + if !ok { + client.Ack(*evt.Request) + continue + } + onInteraction(cfg, db, sqsClient, client, &evt, &callback) + + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + client.Ack(*evt.Request) + if !ok { + continue + } + onEventsAPI(cfg, db, sqsClient, &eventsAPIEvent) + + case socketmode.EventTypeConnecting: + log.Println("[socketmode] connecting to Slack...") + case socketmode.EventTypeConnected: + log.Println("[socketmode] connected") + } + } + }() + + if err := client.Run(); err != nil { + log.Printf("[socketmode] disconnected: %v", err) + } +} + +// onSlashCommand opens the appropriate modal. Must complete within 3 seconds. +func onSlashCommand(cfg *config.Config, db *sql.DB, client *socketmode.Client, evt *socketmode.Event, cmd *goslack.SlashCommand) { + var err error + botToken, tokenErr := resolveBotTokenForWorkspace(context.Background(), cfg, db, cmd.TeamID) + if tokenErr != nil { + log.Printf("[socketmode] resolve slash workspace token failed (%s): %v", cmd.Command, tokenErr) + client.Ack(*evt.Request, map[string]interface{}{ + "response_type": "ephemeral", + "text": "ワークスペース設定の読み込みに失敗しました。しばらく待ってから再試行してください。", + }) + return + } + switch cmd.Command { + case "/question": + err = OpenQuestionModal(botToken, cmd.TriggerID, cmd.ChannelID) + case "/progress": + err = OpenProgressModal(botToken, cmd.TriggerID, cmd.ChannelID) + default: + client.Ack(*evt.Request) + return + } + + if err != nil { + log.Printf("[socketmode] open modal failed (%s): %v", cmd.Command, err) + client.Ack(*evt.Request, map[string]interface{}{ + "response_type": "ephemeral", + "text": "モーダルを開けませんでした。しばらく待ってから再試行してください。", + }) + return + } + client.Ack(*evt.Request) +} + +// onInteraction handles interactive callbacks (block_actions and view_submission). +func onInteraction(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender, client *socketmode.Client, evt *socketmode.Event, cb *goslack.InteractionCallback) { + // Handle block_actions (button clicks) + if cb.Type == goslack.InteractionTypeBlockActions { + for _, action := range cb.ActionCallback.BlockActions { + switch action.ActionID { + case "mentor_reply": + botToken, tokenErr := resolveBotTokenForWorkspace(context.Background(), cfg, db, cb.Team.ID) + if tokenErr != nil { + log.Printf("[socketmode] resolve mentor reply workspace token failed: %v", tokenErr) + continue + } + if err := OpenMentorReplyModal(botToken, cb.TriggerID, action.Value); err != nil { + log.Printf("[socketmode] open mentor reply modal failed: %v", err) + } + case "question_resolved": + onQuestionResolved(cfg, db, action.Value) + case "question_not_resolved": + onQuestionNotResolved(cfg, db, action.Value) + } + } + client.Ack(*evt.Request) + return + } + + if cb.Type != goslack.InteractionTypeViewSubmission { + client.Ack(*evt.Request) + return + } + + // Handle mentor_reply_modal submission + if cb.View.CallbackID == "mentor_reply_modal" { + parts := strings.SplitN(cb.View.PrivateMetadata, "|", 4) + if len(parts) != 4 { + log.Printf("[socketmode] invalid mentor_reply_modal metadata: %s", cb.View.PrivateMetadata) + client.Ack(*evt.Request) + return + } + questionID, channelID, threadTs, userID := parts[0], parts[1], parts[2], parts[3] + botToken, tokenErr := resolveQuestionBotToken(context.Background(), cfg, db, questionID) + if tokenErr != nil { + log.Printf("[socketmode] resolve mentor reply question token failed: %v", tokenErr) + client.Ack(*evt.Request) + return + } + reply := blockVal(cb.View.State.Values, "reply_block", "reply") + text := fmt.Sprintf("<@%s> メンターより:\n%s", userID, reply) + if err := callChatPostMessage(botToken, channelID, threadTs, text); err != nil { + log.Printf("[socketmode] mentor reply post failed: %v", err) + } + client.Ack(*evt.Request, map[string]interface{}{"response_action": "clear"}) + return + } + + // Idempotency: view.id is unique per modal open. + inserted, err := insertEventIfNew(context.Background(), db, cb.View.ID, "view_submission") + if err != nil { + log.Printf("[socketmode] idempotency db error: %v", err) + client.Ack(*evt.Request) + return + } + if !inserted { + client.Ack(*evt.Request) + return + } + + var enqueueErr error + switch cb.View.CallbackID { + case "question_modal": + enqueueErr = enqueueQuestion(sqsClient, cb) + case "progress_modal": + enqueueErr = enqueueProgress(sqsClient, cb) + default: + log.Printf("[socketmode] unknown callback_id: %s", cb.View.CallbackID) + client.Ack(*evt.Request) + return + } + + if enqueueErr != nil { + log.Printf("[socketmode] enqueue failed for %s: %v", cb.View.CallbackID, enqueueErr) + client.Ack(*evt.Request, map[string]interface{}{ + "response_action": "errors", + "errors": map[string]string{ + "title_block": "送信に失敗しました。しばらく待ってから再試行してください。", + }, + }) + return + } + + client.Ack(*evt.Request, map[string]interface{}{"response_action": "clear"}) +} + +// onQuestionResolved handles the "はい" button. +// value format: "questionId|channelId|threadTs|userId" +func onQuestionResolved(cfg *config.Config, db *sql.DB, value string) { + parts := strings.SplitN(value, "|", 4) + if len(parts) != 4 { + log.Printf("[socketmode] invalid question_resolved value: %s", value) + return + } + questionID, channelID, threadTs, userID := parts[0], parts[1], parts[2], parts[3] + botToken, tokenErr := resolveQuestionBotToken(context.Background(), cfg, db, questionID) + if tokenErr != nil { + log.Printf("[socketmode] resolve question resolved token failed: %v", tokenErr) + return + } + + row := db.QueryRowContext(context.Background(), + `UPDATE questions + SET status = 'resolved', updated_at = NOW() + WHERE id = $1 + AND status NOT IN ('resolved', 'escalated') + RETURNING id`, + questionID, + ) + var updatedQuestionID string + if err := row.Scan(&updatedQuestionID); err != nil { + if err == sql.ErrNoRows { + return + } + log.Printf("[socketmode] mark resolved failed: %v", err) + return + } + if _, err := db.ExecContext(context.Background(), + `UPDATE question_sessions + SET status = 'resolved', updated_at = NOW() + WHERE question_id = $1 + AND status NOT IN ('resolved', 'escalated')`, + questionID, + ); err != nil { + log.Printf("[socketmode] mark question session resolved failed: %v", err) + } + + text := fmt.Sprintf("<@%s> ✅ 解決済みとしてマークしました。お役に立てて良かったです!", userID) + if err := callChatPostMessage(botToken, channelID, threadTs, text); err != nil { + log.Printf("[socketmode] post resolved message failed: %v", err) + } +} + +// onQuestionNotResolved handles the "いいえ" button. +// value format: "questionId|channelId|threadTs|userId" +func onQuestionNotResolved(cfg *config.Config, db *sql.DB, value string) { + parts := strings.SplitN(value, "|", 4) + if len(parts) != 4 { + log.Printf("[socketmode] invalid question_not_resolved value: %s", value) + return + } + questionID, channelID, threadTs, userID := parts[0], parts[1], parts[2], parts[3] + botToken, tokenErr := resolveQuestionBotToken(context.Background(), cfg, db, questionID) + if tokenErr != nil { + log.Printf("[socketmode] resolve question not-resolved token failed: %v", tokenErr) + return + } + + var title string + if err := db.QueryRowContext(context.Background(), + `UPDATE questions + SET status = 'escalated', escalated_to_mentor = TRUE, updated_at = NOW() + WHERE id = $1 + AND status NOT IN ('resolved', 'escalated') + RETURNING title`, + questionID, + ).Scan(&title); err != nil { + if err == sql.ErrNoRows { + return + } + log.Printf("[socketmode] mark escalated failed: %v", err) + return + } + if _, err := db.ExecContext(context.Background(), + `UPDATE question_sessions + SET status = 'escalated', updated_at = NOW() + WHERE question_id = $1 + AND status NOT IN ('resolved', 'escalated')`, + questionID, + ); err != nil { + log.Printf("[socketmode] mark question session escalated failed: %v", err) + } + + // Notify user in thread. + userText := fmt.Sprintf("<@%s> メンターに転送しました。しばらくお待ちください 🙏", userID) + if err := callChatPostMessage(botToken, channelID, threadTs, userText); err != nil { + log.Printf("[socketmode] post not-resolved message failed: %v", err) + } + + // Post to mentor channel with a direct link back to the original thread. + mentorChannel := cfg.SlackMentorChannel + if mentorChannel == "" { + log.Printf("[socketmode] SLACK_MENTOR_CHANNEL_ID not set; skipping mentor escalation") + return + } + threadURL := fmt.Sprintf("https://slack.com/archives/%s/p%s", channelID, strings.ReplaceAll(threadTs, ".", "")) + mentorText := fmt.Sprintf("[ユーザー未解決] %s", title) + mentorBlocks := buildMentorEscalationBlocks(title, threadURL) + if err := callChatPostMessageWithBlocks(botToken, mentorChannel, "", mentorText, mentorBlocks); err != nil { + log.Printf("[socketmode] post mentor escalation failed: %v", err) + } +} + +// buildMentorEscalationBlocks builds the Block Kit payload for mentor channel escalation. +func buildMentorEscalationBlocks(title, threadURL string) []map[string]any { + return []map[string]any{ + { + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": fmt.Sprintf("*[ユーザー未解決] %s*\n元スレッド: %s", title, threadURL), + }, + }, + { + "type": "actions", + "elements": []map[string]any{ + { + "type": "button", + "text": map[string]any{ + "type": "plain_text", + "text": "この質問に回答する", + "emoji": true, + }, + "url": threadURL, + }, + }, + }, + } +} + +// callChatPostMessageWithBlocks posts a Block Kit message to a Slack channel. +func callChatPostMessageWithBlocks(botToken, channelID, threadTs, text string, blocks []map[string]any) error { + payload := map[string]any{ + "channel": channelID, + "text": text, + "blocks": blocks, + } + if threadTs != "" { + payload["thread_ts"] = threadTs + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal chat.postMessage payload: %w", err) + } + req, err := http.NewRequest(http.MethodPost, slackAPIBase+"/chat.postMessage", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat.postMessage request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer "+botToken) + + resp, err := slackHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("chat.postMessage http call: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode chat.postMessage response: %w", err) + } + if !result.OK { + return fmt.Errorf("chat.postMessage error: %s", result.Error) + } + return nil +} + +// callChatPostMessage posts a message to a Slack channel/thread via the Web API. +func callChatPostMessage(botToken, channelID, threadTs, text string) error { + payload := map[string]any{ + "channel": channelID, + "thread_ts": threadTs, + "text": text, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal chat.postMessage payload: %w", err) + } + req, err := http.NewRequest(http.MethodPost, slackAPIBase+"/chat.postMessage", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat.postMessage request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer "+botToken) + + resp, err := slackHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("chat.postMessage http call: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode chat.postMessage response: %w", err) + } + if !result.OK { + return fmt.Errorf("chat.postMessage error: %s", result.Error) + } + return nil +} + +// onEventsAPI handles Events API callbacks (e.g. message events). +func onEventsAPI(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender, event *slackevents.EventsAPIEvent) { + if event.InnerEvent.Type != "message" { + return + } + msg, ok := event.InnerEvent.Data.(*slackevents.MessageEvent) + if !ok { + return + } + // Skip bot messages, edited messages, etc. + if msg.BotID != "" || msg.SubType != "" { + return + } + // Only process thread replies (not top-level messages) + if msg.ThreadTimeStamp == "" { + return + } + // Skip the thread-parent itself + if msg.ThreadTimeStamp == msg.TimeStamp { + return + } + + questionID, userID, title, found, err := findQuestionByThread(context.Background(), db, msg.Channel, msg.ThreadTimeStamp) + if err != nil { + log.Printf("[socketmode] findQuestionByThread error: %v", err) + return + } + if !found { + return + } + + followup := questionFollowupMessage{ + QuestionID: questionID, + ChannelID: msg.Channel, + ThreadTs: msg.ThreadTimeStamp, + MessageTs: msg.TimeStamp, + UserID: userID, + ReplyText: msg.Text, + OriginalTitle: title, + } + body, err := json.Marshal(followup) + if err != nil { + log.Printf("[socketmode] marshal followup message: %v", err) + return + } + if err := sqsClient.Enqueue(context.Background(), sqsClient.QuestionFollowupURL(), string(body)); err != nil { + log.Printf("[socketmode] enqueue followup failed: %v", err) + } +} + +// findQuestionByThread looks up a question by channel + thread_ts. +func findQuestionByThread(ctx context.Context, db *sql.DB, channelID, threadTs string) (questionID, userID, title string, found bool, err error) { + row := db.QueryRowContext(ctx, + `SELECT q.id, q.slack_user_id, q.title + FROM questions q + JOIN question_sessions qs ON qs.question_id = q.id + WHERE q.slack_channel_id = $1 + AND q.slack_thread_ts = $2 + AND qs.status = 'awaiting_user' + LIMIT 1`, + channelID, threadTs, + ) + if err = row.Scan(&questionID, &userID, &title); err != nil { + if err == sql.ErrNoRows { + return "", "", "", false, nil + } + return "", "", "", false, err + } + return questionID, userID, title, true, nil +} + +// questionFollowupMessage is the SQS payload for question followups. +type questionFollowupMessage struct { + QuestionID string `json:"question_id"` + ChannelID string `json:"channel_id"` + ThreadTs string `json:"thread_ts"` + MessageTs string `json:"message_ts"` + UserID string `json:"user_id"` + ReplyText string `json:"reply_text"` + OriginalTitle string `json:"original_title"` +} + +func enqueueQuestion(sqsClient sqs.Sender, cb *goslack.InteractionCallback) error { + vals := cb.View.State.Values + msg := questionNewMessage{ + UserID: cb.User.ID, + SlackWorkspaceID: cb.Team.ID, + ChannelID: cb.View.PrivateMetadata, + Title: blockVal(vals, "title_block", "title"), + Body: blockVal(vals, "body_block", "body"), + ErrorMessage: blockVal(vals, "error_block", "error_message"), + Tried: blockVal(vals, "tried_block", "tried"), + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + return sqsClient.Enqueue(context.Background(), sqsClient.QuestionNewURL(), string(body)) +} + +func enqueueProgress(sqsClient sqs.Sender, cb *goslack.InteractionCallback) error { + vals := cb.View.State.Values + sosChecked := len(vals["sos_block"]["sos_flag"].SelectedOptions) > 0 + msg := progressMessage{ + UserID: cb.User.ID, + SlackWorkspaceID: cb.Team.ID, + ChannelID: cb.View.PrivateMetadata, + Phase: blockSelectedVal(vals, "phase_block", "phase"), + CurrentStatus: blockVal(vals, "status_block", "current_status"), + Blockers: blockVal(vals, "blockers_block", "blockers"), + SOSFlag: sosChecked, + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + return sqsClient.Enqueue(context.Background(), sqsClient.ProgressURL(), string(body)) +} + +// blockVal extracts a plain_text_input value from slack-go's ViewState. +func blockVal(vals map[string]map[string]goslack.BlockAction, blockID, actionID string) string { + if b, ok := vals[blockID]; ok { + if a, ok := b[actionID]; ok { + return a.Value + } + } + return "" +} + +// blockSelectedVal extracts a static_select value from slack-go's ViewState. +func blockSelectedVal(vals map[string]map[string]goslack.BlockAction, blockID, actionID string) string { + if b, ok := vals[blockID]; ok { + if a, ok := b[actionID]; ok && a.SelectedOption.Value != "" { + return a.SelectedOption.Value + } + } + return "" +} diff --git a/apps/api/internal/slack/socketmode_test.go b/apps/api/internal/slack/socketmode_test.go new file mode 100644 index 0000000..838b83c --- /dev/null +++ b/apps/api/internal/slack/socketmode_test.go @@ -0,0 +1,189 @@ +package slack + +import ( + "database/sql" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + "github.com/Asheze1127/HackHub/apps/api/internal/config" +) + +type recordingTransport struct { + bodies []string +} + +func (t *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + t.bodies = append(t.bodies, string(body)) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + Request: req, + }, nil +} + +func TestOnQuestionResolvedUpdatesSessionAndPostsReply(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery("SELECT t.slack_workspace_id"). + WithArgs("question-1"). + WillReturnError(sql.ErrNoRows) + mock.ExpectQuery("UPDATE questions"). + WithArgs("question-1"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("question-1")) + mock.ExpectExec("UPDATE question_sessions"). + WithArgs("question-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + transport := &recordingTransport{} + previousClient := slackHTTPClient + slackHTTPClient = &http.Client{Transport: transport} + t.Cleanup(func() { + slackHTTPClient = previousClient + }) + + onQuestionResolved(&config.Config{SlackBotToken: "xoxb-fallback"}, db, "question-1|C123|111.222|U123") + + if len(transport.bodies) != 1 { + t.Fatalf("expected 1 Slack post, got %d", len(transport.bodies)) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(transport.bodies[0]), &payload); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if payload["channel"] != "C123" { + t.Fatalf("expected channel C123, got %v", payload["channel"]) + } + if !strings.Contains(payload["text"].(string), "解決済みとしてマークしました") { + t.Fatalf("unexpected text: %v", payload["text"]) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} + +func TestOnQuestionNotResolvedEscalatesSessionAndMentor(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery("SELECT t.slack_workspace_id"). + WithArgs("question-2"). + WillReturnError(sql.ErrNoRows) + mock.ExpectQuery("UPDATE questions"). + WithArgs("question-2"). + WillReturnRows(sqlmock.NewRows([]string{"title"}).AddRow("ビルドが通らない")) + mock.ExpectExec("UPDATE question_sessions"). + WithArgs("question-2"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + transport := &recordingTransport{} + previousClient := slackHTTPClient + slackHTTPClient = &http.Client{Transport: transport} + t.Cleanup(func() { + slackHTTPClient = previousClient + }) + + onQuestionNotResolved(&config.Config{ + SlackBotToken: "xoxb-fallback", + SlackMentorChannel: "CMENTOR", + }, db, "question-2|C123|333.444|U999") + + if len(transport.bodies) != 2 { + t.Fatalf("expected 2 Slack posts, got %d", len(transport.bodies)) + } + + var userPayload map[string]any + if err := json.Unmarshal([]byte(transport.bodies[0]), &userPayload); err != nil { + t.Fatalf("json.Unmarshal user payload: %v", err) + } + if !strings.Contains(userPayload["text"].(string), "メンターに転送しました") { + t.Fatalf("unexpected user text: %v", userPayload["text"]) + } + + var mentorPayload map[string]any + if err := json.Unmarshal([]byte(transport.bodies[1]), &mentorPayload); err != nil { + t.Fatalf("json.Unmarshal mentor payload: %v", err) + } + if mentorPayload["channel"] != "CMENTOR" { + t.Fatalf("expected mentor channel CMENTOR, got %v", mentorPayload["channel"]) + } + if !strings.Contains(mentorPayload["text"].(string), "[ユーザー未解決]") { + t.Fatalf("unexpected mentor text: %v", mentorPayload["text"]) + } + blocks, ok := mentorPayload["blocks"].([]any) + if !ok || len(blocks) < 2 { + t.Fatalf("expected mentor blocks, got %v", mentorPayload["blocks"]) + } + actions, ok := blocks[1].(map[string]any) + if !ok { + t.Fatalf("expected actions block, got %T", blocks[1]) + } + elements, ok := actions["elements"].([]any) + if !ok || len(elements) != 1 { + t.Fatalf("expected mentor action element, got %v", actions["elements"]) + } + button, ok := elements[0].(map[string]any) + if !ok { + t.Fatalf("expected button element, got %T", elements[0]) + } + if button["url"] != "https://slack.com/archives/C123/p333444" { + t.Fatalf("unexpected mentor button url: %v", button["url"]) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} + +func TestOnQuestionNotResolvedIsIdempotentAfterFirstEscalation(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + mock.ExpectQuery("SELECT t.slack_workspace_id"). + WithArgs("question-3"). + WillReturnError(sql.ErrNoRows) + mock.ExpectQuery("UPDATE questions"). + WithArgs("question-3"). + WillReturnError(sql.ErrNoRows) + + transport := &recordingTransport{} + previousClient := slackHTTPClient + slackHTTPClient = &http.Client{Transport: transport} + t.Cleanup(func() { + slackHTTPClient = previousClient + }) + + onQuestionNotResolved(&config.Config{ + SlackBotToken: "xoxb-fallback", + SlackMentorChannel: "CMENTOR", + }, db, "question-3|C123|333.444|U999") + + if len(transport.bodies) != 0 { + t.Fatalf("expected no Slack posts on duplicate escalation, got %d", len(transport.bodies)) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet expectations: %v", err) + } +} diff --git a/apps/api/internal/slack/token_resolver.go b/apps/api/internal/slack/token_resolver.go new file mode 100644 index 0000000..8cf844a --- /dev/null +++ b/apps/api/internal/slack/token_resolver.go @@ -0,0 +1,131 @@ +package slack + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "database/sql" + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/Asheze1127/HackHub/apps/api/internal/config" +) + +func resolveBotTokenForWorkspace(ctx context.Context, cfg *config.Config, db *sql.DB, workspaceID string) (string, error) { + trimmedWorkspaceID := strings.TrimSpace(workspaceID) + if trimmedWorkspaceID == "" { + return cfg.SlackBotToken, nil + } + + var ciphertext sql.NullString + err := db.QueryRowContext(ctx, + `SELECT bot_access_token_ciphertext + FROM slack_installations + WHERE workspace_id = $1 + LIMIT 1`, + trimmedWorkspaceID, + ).Scan(&ciphertext) + if err != nil { + if err == sql.ErrNoRows { + return cfg.SlackBotToken, nil + } + return "", err + } + if !ciphertext.Valid || strings.TrimSpace(ciphertext.String) == "" { + return cfg.SlackBotToken, nil + } + + token, err := decryptSlackAccessToken(ciphertext.String, readSlackInstallationSecret(cfg)) + if err != nil { + return "", err + } + return token, nil +} + +func resolveQuestionBotToken(ctx context.Context, cfg *config.Config, db *sql.DB, questionID string) (string, error) { + workspaceID, err := lookupQuestionWorkspaceID(ctx, db, questionID) + if err != nil { + return "", err + } + return resolveBotTokenForWorkspace(ctx, cfg, db, workspaceID) +} + +func lookupQuestionWorkspaceID(ctx context.Context, db *sql.DB, questionID string) (string, error) { + var workspaceID sql.NullString + err := db.QueryRowContext(ctx, + `SELECT t.slack_workspace_id + FROM questions q + LEFT JOIN teams t + ON t.id = q.team_id + WHERE q.id = $1 + LIMIT 1`, + questionID, + ).Scan(&workspaceID) + if err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + if !workspaceID.Valid { + return "", nil + } + return strings.TrimSpace(workspaceID.String), nil +} + +func readSlackInstallationSecret(cfg *config.Config) string { + if secret := strings.TrimSpace(os.Getenv("SLACK_INSTALLATION_ENCRYPTION_SECRET")); secret != "" { + return secret + } + if secret := strings.TrimSpace(cfg.InternalAuthSecret); secret != "" { + return secret + } + return "dev-hackhub-slack-installation-secret" +} + +func decryptSlackAccessToken(ciphertextRaw, secret string) (string, error) { + parts := strings.Split(ciphertextRaw, ".") + if len(parts) != 4 || parts[0] != "v1" { + return "", fmt.Errorf("invalid slack installation token ciphertext") + } + + iv, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode slack token iv: %w", err) + } + body, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return "", fmt.Errorf("decode slack token body: %w", err) + } + tag, err := base64.RawURLEncoding.DecodeString(parts[3]) + if err != nil { + return "", fmt.Errorf("decode slack token tag: %w", err) + } + + block, err := aes.NewCipher(deriveSlackInstallationKey(secret)) + if err != nil { + return "", fmt.Errorf("create slack token cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("create slack token gcm: %w", err) + } + + encrypted := make([]byte, 0, len(body)+len(tag)) + encrypted = append(encrypted, body...) + encrypted = append(encrypted, tag...) + + plaintext, err := gcm.Open(nil, iv, encrypted, nil) + if err != nil { + return "", fmt.Errorf("decrypt slack installation token: %w", err) + } + return string(plaintext), nil +} + +func deriveSlackInstallationKey(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} diff --git a/apps/api/internal/slack/token_resolver_test.go b/apps/api/internal/slack/token_resolver_test.go new file mode 100644 index 0000000..ffc81d5 --- /dev/null +++ b/apps/api/internal/slack/token_resolver_test.go @@ -0,0 +1,83 @@ +package slack + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "database/sql" + "encoding/base64" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + "github.com/Asheze1127/HackHub/apps/api/internal/config" +) + +func TestResolveBotTokenForWorkspaceFallsBackWithoutInstallation(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + defer db.Close() + + cfg := &config.Config{ + SlackBotToken: "xoxb-fallback", + InternalAuthSecret: "test-secret", + } + + mock.ExpectQuery("SELECT bot_access_token_ciphertext"). + WithArgs("T123"). + WillReturnError(sql.ErrNoRows) + + token, err := resolveBotTokenForWorkspace(context.Background(), cfg, db, "T123") + if err != nil { + t.Fatalf("resolveBotTokenForWorkspace returned error: %v", err) + } + if token != "xoxb-fallback" { + t.Fatalf("expected fallback token, got %q", token) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet mock expectations: %v", err) + } +} + +func TestDecryptSlackAccessToken(t *testing.T) { + ciphertext := encryptSlackAccessTokenForTest(t, "xoxb-test-token", "test-secret") + + token, err := decryptSlackAccessToken(ciphertext, "test-secret") + if err != nil { + t.Fatalf("decryptSlackAccessToken returned error: %v", err) + } + if token != "xoxb-test-token" { + t.Fatalf("expected xoxb-test-token, got %q", token) + } +} + +func encryptSlackAccessTokenForTest(t *testing.T, token, secret string) string { + t.Helper() + + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + t.Fatalf("rand.Read: %v", err) + } + + block, err := aes.NewCipher(deriveSlackInstallationKey(secret)) + if err != nil { + t.Fatalf("aes.NewCipher: %v", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatalf("cipher.NewGCM: %v", err) + } + + sealed := gcm.Seal(nil, iv, []byte(token), nil) + tagSize := gcm.Overhead() + body := sealed[:len(sealed)-tagSize] + tag := sealed[len(sealed)-tagSize:] + + return "v1." + + base64.RawURLEncoding.EncodeToString(iv) + "." + + base64.RawURLEncoding.EncodeToString(body) + "." + + base64.RawURLEncoding.EncodeToString(tag) +} diff --git a/apps/console/.gitignore b/apps/console/.gitignore new file mode 100644 index 0000000..353d5e2 --- /dev/null +++ b/apps/console/.gitignore @@ -0,0 +1,19 @@ +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store + +# env files +.env* + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/console/app/api/questions/[questionId]/status/route.ts b/apps/console/app/api/questions/[questionId]/status/route.ts new file mode 100644 index 0000000..7e2b5e9 --- /dev/null +++ b/apps/console/app/api/questions/[questionId]/status/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" +import { buildViewerHeaders } from "@/shared/lib/internal-auth" +import { getConsoleSession } from "@/shared/lib/session" + +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080" + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ questionId: string }> }, +) { + const session = await getConsoleSession() + if (!session) { + return NextResponse.json({ error: "missing session" }, { status: 401 }) + } + + const { questionId } = await params + const body = await request.text() + const response = await fetch(`${API_BASE}/api/questions/${questionId}/status`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...buildViewerHeaders(session), + }, + body, + cache: "no-store", + }) + + const responseText = await response.text() + return new NextResponse(responseText, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }) +} diff --git a/apps/console/app/auth/login/route.ts b/apps/console/app/auth/login/route.ts new file mode 100644 index 0000000..ef8b3a7 --- /dev/null +++ b/apps/console/app/auth/login/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import { authenticateLocalUser } from "@/shared/lib/auth-store" +import { buildSessionFromBinding } from "@/shared/lib/session" +import { defaultPathForSession, serializeConsoleSession, SESSION_COOKIE_NAME } from "@/shared/lib/session-schema" + +function sanitizeRedirectPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) { + return "" + } + return value +} + +function toLoginReasonCode(error: unknown): string { + const message = error instanceof Error ? error.message : "" + switch (message) { + case "このアカウントには利用可能なロール割り当てがありません。": + return "missing_binding" + case "このアカウントは現在利用できません。": + return "account_disabled" + default: + return "invalid_credentials" + } +} + +export async function POST(request: Request) { + const formData = await request.formData() + const email = String(formData.get("email") ?? "") + const password = String(formData.get("password") ?? "") + const redirectTo = sanitizeRedirectPath(String(formData.get("redirect_to") ?? "").trim()) + + try { + const { user, bindings } = await authenticateLocalUser(email, password) + const targetBinding = bindings[0] + const session = buildSessionFromBinding(user, targetBinding) + const destination = + bindings.length > 1 + ? `/switch-context${redirectTo ? `?redirect=${encodeURIComponent(redirectTo)}` : ""}` + : redirectTo || defaultPathForSession(session) + + const response = NextResponse.redirect(new URL(destination, request.url)) + response.cookies.set(SESSION_COOKIE_NAME, serializeConsoleSession(session), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 12, + }) + return response + } catch (error) { + return NextResponse.redirect(new URL(`/login?reason=${toLoginReasonCode(error)}`, request.url)) + } +} diff --git a/apps/console/app/auth/logout/route.ts b/apps/console/app/auth/logout/route.ts new file mode 100644 index 0000000..f05128f --- /dev/null +++ b/apps/console/app/auth/logout/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server" +import { SESSION_COOKIE_NAME } from "@/shared/lib/session-schema" + +export async function POST(request: Request) { + const response = NextResponse.redirect(new URL("/login", request.url)) + response.cookies.set(SESSION_COOKIE_NAME, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }) + return response +} diff --git a/apps/console/app/auth/signup/route.ts b/apps/console/app/auth/signup/route.ts new file mode 100644 index 0000000..4684002 --- /dev/null +++ b/apps/console/app/auth/signup/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import { createLocalUserWithInitialBinding } from "@/shared/lib/auth-store" +import { buildSessionFromBinding } from "@/shared/lib/session" +import { defaultPathForSession, isConsoleRole, serializeConsoleSession, SESSION_COOKIE_NAME } from "@/shared/lib/session-schema" + +function sanitizeRedirectPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) { + return "" + } + return value +} + +function toSignupReasonCode(error: unknown): string { + const message = error instanceof Error ? error.message : "" + switch (message) { + case "そのメールアドレスはすでに登録されています。": + return "email_exists" + case "表示名を入力してください。": + return "missing_display_name" + case "メールアドレスを入力してください。": + return "missing_email" + case "パスワードは 8 文字以上で入力してください。": + return "weak_password" + case "このロールではハッカソンを選択してください。": + case "選択したハッカソンが見つかりません。": + return "missing_hackathon" + default: + return "signup_failed" + } +} + +export async function POST(request: Request) { + const formData = await request.formData() + const displayName = String(formData.get("display_name") ?? "") + const email = String(formData.get("email") ?? "") + const password = String(formData.get("password") ?? "") + const confirmPassword = String(formData.get("confirm_password") ?? "") + const role = String(formData.get("role") ?? "") + const hackathonId = String(formData.get("hackathon_id") ?? "").trim() || null + const redirectTo = sanitizeRedirectPath(String(formData.get("redirect_to") ?? "").trim()) + + if (!isConsoleRole(role)) { + return NextResponse.redirect(new URL("/signup?reason=invalid_role", request.url)) + } + if (password !== confirmPassword) { + return NextResponse.redirect(new URL("/signup?reason=password_mismatch", request.url)) + } + + try { + const { user, binding } = await createLocalUserWithInitialBinding({ + displayName, + email, + password, + role, + hackathonId, + }) + const session = buildSessionFromBinding(user, binding) + const response = NextResponse.redirect(new URL(redirectTo || defaultPathForSession(session), request.url)) + response.cookies.set(SESSION_COOKIE_NAME, serializeConsoleSession(session), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 12, + }) + return response + } catch (error) { + const reason = toSignupReasonCode(error) + return NextResponse.redirect(new URL(`/signup?reason=${encodeURIComponent(reason)}`, request.url)) + } +} diff --git a/apps/console/app/auth/switch-context/route.ts b/apps/console/app/auth/switch-context/route.ts new file mode 100644 index 0000000..ec05364 --- /dev/null +++ b/apps/console/app/auth/switch-context/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { cookies } from "next/headers" +import { getBindingForUser } from "@/shared/lib/auth-store" +import { buildSessionFromBinding } from "@/shared/lib/session" +import { defaultPathForSession, parseConsoleSession, serializeConsoleSession, SESSION_COOKIE_NAME } from "@/shared/lib/session-schema" + +function sanitizeRedirectPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) { + return "" + } + return value +} + +export async function POST(request: Request) { + const formData = await request.formData() + const bindingId = String(formData.get("binding_id") ?? "").trim() + const redirectTo = sanitizeRedirectPath(String(formData.get("redirect_to") ?? "").trim()) + const cookieStore = await cookies() + const cookieValue = cookieStore.get(SESSION_COOKIE_NAME)?.value + const session = parseConsoleSession(cookieValue) + + if (!session || !bindingId) { + return NextResponse.redirect(new URL("/login?reason=session_required", request.url)) + } + + const binding = await getBindingForUser(session.userId, bindingId) + if (!binding) { + return NextResponse.redirect(new URL("/switch-context?reason=invalid_binding", request.url)) + } + + const nextSession = buildSessionFromBinding( + { + id: session.userId, + email: session.email, + displayName: session.displayName, + }, + binding, + ) + + const response = NextResponse.redirect(new URL(redirectTo || defaultPathForSession(nextSession), request.url)) + response.cookies.set(SESSION_COOKIE_NAME, serializeConsoleSession(nextSession), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 12, + }) + return response +} diff --git a/apps/console/app/forbidden/page.tsx b/apps/console/app/forbidden/page.tsx new file mode 100644 index 0000000..dd49dd8 --- /dev/null +++ b/apps/console/app/forbidden/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link" + +export default async function ForbiddenPage({ + searchParams, +}: { + searchParams: Promise<{ from?: string | string[] }> +}) { + const params = await searchParams + const from = Array.isArray(params.from) ? params.from[0] : params.from + + return ( +
+

アクセス制限

+

+ 現在のロールではこの画面にアクセスできません。 +

+

+ {from ? `アクセス元: ${from}` : "ロールまたはスコープを切り替えてから再試行してください。"} +

+ + ログイン画面へ戻る + +
+ ) +} diff --git a/apps/console/app/github/oauth/callback/route.ts b/apps/console/app/github/oauth/callback/route.ts new file mode 100644 index 0000000..2169bc6 --- /dev/null +++ b/apps/console/app/github/oauth/callback/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server" +import { + exchangeGitHubOauthCode, + fetchGitHubViewer, + parseGitHubOauthState, + upsertGitHubConnection, +} from "@/shared/lib/github-connection-store" + +export async function GET(request: Request) { + const url = new URL(request.url) + const code = url.searchParams.get("code")?.trim() ?? "" + const stateRaw = url.searchParams.get("state")?.trim() ?? "" + const oauthError = url.searchParams.get("error")?.trim() ?? "" + + const defaultRedirect = new URL("/hacker", request.url) + + if (oauthError) { + defaultRedirect.searchParams.set("notice", "github_error") + defaultRedirect.searchParams.set("message", `GitHub 側で連携が中断されました: ${oauthError}`) + return NextResponse.redirect(defaultRedirect) + } + + if (!code || !stateRaw) { + defaultRedirect.searchParams.set("notice", "github_error") + defaultRedirect.searchParams.set("message", "GitHub OAuth の応答が不正です。") + return NextResponse.redirect(defaultRedirect) + } + + const state = parseGitHubOauthState(stateRaw) + if (!state) { + defaultRedirect.searchParams.set("notice", "github_error") + defaultRedirect.searchParams.set("message", "GitHub OAuth の state 検証に失敗しました。") + return NextResponse.redirect(defaultRedirect) + } + + const redirectUrl = new URL(state.redirectTo, request.url) + + try { + const token = await exchangeGitHubOauthCode(code) + const viewer = await fetchGitHubViewer(token.accessToken) + await upsertGitHubConnection({ + userId: state.actorUserId, + accessToken: token.accessToken, + scope: token.scope, + githubUserId: viewer.githubUserId, + githubLogin: viewer.githubLogin, + githubName: viewer.githubName, + }) + + redirectUrl.searchParams.set("notice", "github_connected") + return NextResponse.redirect(redirectUrl) + } catch (error) { + redirectUrl.searchParams.set("notice", "github_error") + redirectUrl.searchParams.set("message", error instanceof Error ? error.message : "GitHub 連携に失敗しました。") + return NextResponse.redirect(redirectUrl) + } +} diff --git a/apps/console/app/globals.css b/apps/console/app/globals.css new file mode 100644 index 0000000..75ed073 --- /dev/null +++ b/apps/console/app/globals.css @@ -0,0 +1,92 @@ +@import "tailwindcss"; + +:root { + --background: #f5f0e8; + --background-strong: #efe5d4; + --foreground: #1e293b; + --heading: #0f172a; + --muted: #475569; + --eyebrow: #0f766e; + --panel: rgba(255, 255, 255, 0.84); + --panel-strong: rgba(255, 255, 255, 0.94); + --panel-solid: rgba(255, 255, 255, 0.96); + --panel-soft: rgba(255, 250, 243, 0.92); + --border: rgba(30, 41, 59, 0.12); + --accent: #0f766e; + --accent-strong: #155e75; + --alert: #b91c1c; + --chip-bg: #0f172a; + --chip-fg: #ffffff; + --warm-bg: rgba(255, 247, 237, 0.96); + --warm-border: rgba(251, 191, 36, 0.32); + --warm-text: #92400e; + --success-bg: rgba(236, 253, 245, 0.96); + --success-border: rgba(16, 185, 129, 0.28); + --success-text: #047857; + --danger-bg: rgba(254, 242, 242, 0.96); + --danger-border: rgba(248, 113, 113, 0.32); + --danger-text: #991b1b; + --input-bg: rgba(255, 255, 255, 0.98); + --font-body: "Hiragino Sans", "Yu Gothic", "BIZ UDPGothic", sans-serif; + --font-heading: "Hiragino Mincho ProN", "Yu Mincho", serif; +} + +[data-theme="dark"] { + --background: #07111a; + --background-strong: #0f1b27; + --foreground: #dbe4ee; + --heading: #f8fafc; + --muted: #a1afc0; + --eyebrow: #5eead4; + --panel: rgba(10, 18, 28, 0.82); + --panel-strong: rgba(8, 15, 24, 0.92); + --panel-solid: rgba(13, 22, 32, 0.97); + --panel-soft: rgba(49, 36, 12, 0.7); + --border: rgba(148, 163, 184, 0.18); + --accent: #14b8a6; + --accent-strong: #0f766e; + --alert: #ef4444; + --chip-bg: #dbe4ee; + --chip-fg: #07111a; + --warm-bg: rgba(69, 49, 14, 0.62); + --warm-border: rgba(245, 158, 11, 0.32); + --warm-text: #fde68a; + --success-bg: rgba(6, 78, 59, 0.52); + --success-border: rgba(52, 211, 153, 0.28); + --success-text: #a7f3d0; + --danger-bg: rgba(69, 20, 28, 0.72); + --danger-border: rgba(248, 113, 113, 0.28); + --danger-text: #fecaca; + --input-bg: rgba(13, 22, 32, 0.98); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-body); + --font-mono: var(--font-body); +} + +html { + color-scheme: light; + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 28%), + radial-gradient(circle at bottom right, rgba(21, 94, 117, 0.16), transparent 34%), + linear-gradient(180deg, color-mix(in srgb, var(--background) 72%, white) 0%, var(--background-strong) 100%); +} + +[data-theme="dark"] { + color-scheme: dark; +} + +body { + min-height: 100vh; + color: var(--foreground); + font-family: var(--font-body), sans-serif; + transition: background-color 160ms ease, color 160ms ease; +} + +a { + color: inherit; + text-decoration: none; +} diff --git a/apps/console/app/hacker/github/connect/route.ts b/apps/console/app/hacker/github/connect/route.ts new file mode 100644 index 0000000..b3b3355 --- /dev/null +++ b/apps/console/app/hacker/github/connect/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server" +import { buildGitHubOauthUrl } from "@/shared/lib/github-connection-store" +import { getConsoleSession } from "@/shared/lib/session" + +export async function GET(request: Request) { + const session = await getConsoleSession() + if (!session) { + return NextResponse.redirect(new URL("/login?reason=session_required", request.url)) + } + if (session.role !== "hacker") { + return NextResponse.redirect(new URL("/forbidden?from=/hacker/github/connect", request.url)) + } + + const redirectTo = session.hackathonId + ? `/hacker?hackathon_id=${encodeURIComponent(session.hackathonId)}` + : "/hacker" + + try { + return NextResponse.redirect(buildGitHubOauthUrl(session.userId, redirectTo)) + } catch (error) { + const redirectUrl = new URL(redirectTo, request.url) + redirectUrl.searchParams.set("notice", "github_error") + if (error instanceof Error) { + redirectUrl.searchParams.set("message", error.message) + } + return NextResponse.redirect(redirectUrl) + } +} diff --git a/apps/console/app/hacker/github/disconnect/route.ts b/apps/console/app/hacker/github/disconnect/route.ts new file mode 100644 index 0000000..9f82d48 --- /dev/null +++ b/apps/console/app/hacker/github/disconnect/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server" +import { revokeGitHubConnection } from "@/shared/lib/github-connection-store" +import { getConsoleSession } from "@/shared/lib/session" + +export async function POST(request: Request) { + const session = await getConsoleSession() + if (!session) { + return NextResponse.redirect(new URL("/login?reason=session_required", request.url)) + } + if (session.role !== "hacker") { + return NextResponse.redirect(new URL("/forbidden?from=/hacker/github/disconnect", request.url)) + } + + const redirectTo = session.hackathonId + ? `/hacker?hackathon_id=${encodeURIComponent(session.hackathonId)}` + : "/hacker" + + await revokeGitHubConnection(session.userId) + + const redirectUrl = new URL(redirectTo, request.url) + redirectUrl.searchParams.set("notice", "github_disconnected") + return NextResponse.redirect(redirectUrl) +} diff --git a/apps/console/app/hacker/page.tsx b/apps/console/app/hacker/page.tsx new file mode 100644 index 0000000..a59a797 --- /dev/null +++ b/apps/console/app/hacker/page.tsx @@ -0,0 +1,346 @@ +import Link from "next/link" +import { findGitHubConnectionForUser } from "@/shared/lib/github-connection-store" +import { listPresentationDigestsForHackathon } from "@/shared/lib/judge-digest-store" +import { listPortfolioEntriesForUser } from "@/shared/lib/portfolio-store" +import { listReceivedScoutsForUser } from "@/shared/lib/scout-store" +import { getConsoleSession } from "@/shared/lib/session" + +type HackerPageProps = { + searchParams: Promise<{ + notice?: string | string[] + message?: string | string[] + hackathon_id?: string | string[] + }> +} + +function readParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + return value ?? null +} + +export default async function HackerPage({ searchParams }: HackerPageProps) { + const params = await searchParams + const session = await getConsoleSession() + const githubConnection = session ? await findGitHubConnectionForUser(session.userId) : null + const portfolioEntries = session ? await listPortfolioEntriesForUser(session.userId) : [] + const receivedScouts = session ? await listReceivedScoutsForUser(session.userId) : [] + const digestPairs = await Promise.all( + portfolioEntries.map(async (entry) => [ + entry.hackathonId, + await listPresentationDigestsForHackathon(entry.hackathonId).catch(() => []), + ] as const), + ) + const digestsByHackathon = new Map(digestPairs) + const notice = readParam(params.notice) + const message = readParam(params.message) + + return ( +
+
+

参加者

+

+ 実績整理とスカウト受信設定 +

+

+ ハッカソンごとの参加履歴、スカウト受信設定、portfolio 下書きを同じ画面で管理します。最後に Markdown として export し、そのまま提出資料や卒業時の portfolio の土台に使えます。 +

+
+ + Portfolio を Markdown で出力 + +
+ {notice === "scout_pref_saved" ? ( +

+ スカウト受信設定を保存しました。 +

+ ) : null} + {notice === "portfolio_saved" ? ( +

+ portfolio 下書きを保存しました。 +

+ ) : null} + {notice === "digest_imported" ? ( +

+ judge digest を portfolio 下書きに取り込みました。 +

+ ) : null} + {notice === "github_connected" ? ( +

+ GitHub 連携を保存しました。private repo を使う場合は、同じ repo URL を portfolio に保存してください。 +

+ ) : null} + {notice === "github_disconnected" ? ( +

+ GitHub 連携を解除しました。 +

+ ) : null} + {notice === "scout_pref_error" ? ( +

+ {message ?? "スカウト受信設定を更新できませんでした。"} +

+ ) : null} + {notice === "portfolio_error" ? ( +

+ {message ?? "portfolio を更新できませんでした。"} +

+ ) : null} + {notice === "github_error" ? ( +

+ {message ?? "GitHub 連携に失敗しました。"} +

+ ) : null} +
+ +
+
+
+

GitHub 連携

+ {githubConnection ? ( +
+

+ 接続中: @{githubConnection.githubLogin} + {githubConnection.githubName ? ` (${githubConnection.githubName})` : ""} +

+

scope: {githubConnection.scope || "未取得"}

+

token hint: {githubConnection.accessTokenHint ?? "保存済み"}

+

最終更新: {new Date(githubConnection.updatedAt).toLocaleString("ja-JP")}

+
+ ) : ( +

+ private repo を judge digest で読ませる場合は、ここで GitHub を連携してください。judge 側は同じハッカソン内で、あなたが保存した repo URL に一致する場合のみ、この連携を利用します。 +

+ )} + +
+ + {githubConnection ? "GitHub を再連携" : "GitHub を連携する"} + + {githubConnection ? ( +
+ +
+ ) : null} +
+
+ +

参加情報と portfolio 下書き

+ {portfolioEntries.length === 0 ? ( +

+ まだ参加者としてのハッカソン参加情報がありません。`参加者` ロールでアカウントを作成してください。 +

+ ) : ( +
+ {portfolioEntries.map((entry) => ( +
+

{entry.hackathonName}

+

+ 参加開始: {new Date(entry.joinedAt).toLocaleString("ja-JP")} +

+

受信済みスカウト: {entry.receivedScoutCount} 件

+ +
+ +

スカウト受信設定

+ + + +
+ +
+ + + +
+ + + +
+ +