diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index 84fcfc38..f08f18a6 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -38,18 +38,18 @@
### Votes
-- [ ] **VOTE-01**: 투표 현황 조회 (GET /solutions/{solution_id}/votes)
-- [ ] **VOTE-02**: 투표하기 (POST /solutions/{solution_id}/votes)
-- [ ] **VOTE-03**: 투표 취소 (DELETE /solutions/{solution_id}/votes)
-- [ ] **VOTE-04**: 솔루션 채택 (POST /solutions/{solution_id}/adopt)
-- [ ] **VOTE-05**: 채택 취소 (DELETE /solutions/{solution_id}/adopt)
+- [x] **VOTE-01**: 투표 현황 조회 (GET /solutions/{solution_id}/votes) ✓
+- [x] **VOTE-02**: 투표하기 (POST /solutions/{solution_id}/votes) ✓
+- [x] **VOTE-03**: 투표 취소 (DELETE /solutions/{solution_id}/votes) ✓
+- [x] **VOTE-04**: 솔루션 채택 (POST /solutions/{solution_id}/adopt) ✓
+- [x] **VOTE-05**: 채택 취소 (DELETE /solutions/{solution_id}/adopt) ✓
### Comments
-- [ ] **CMNT-01**: 댓글 목록 조회 (GET /posts/{post_id}/comments)
-- [ ] **CMNT-02**: 댓글 작성 (POST /posts/{post_id}/comments)
-- [ ] **CMNT-03**: 댓글 수정 (PATCH /comments/{comment_id})
-- [ ] **CMNT-04**: 댓글 삭제 (DELETE /comments/{comment_id})
+- [x] **CMNT-01**: 댓글 목록 조회 (GET /posts/{post_id}/comments) ✓
+- [x] **CMNT-02**: 댓글 작성 (POST /posts/{post_id}/comments) ✓
+- [x] **CMNT-03**: 댓글 수정 (PATCH /comments/{comment_id}) ✓
+- [x] **CMNT-04**: 댓글 삭제 (DELETE /comments/{comment_id}) ✓
### Rankings
@@ -136,15 +136,15 @@ Deferred to future release.
| Requirement | Status |
|-------------|--------|
-| VOTE-01 | Pending |
-| VOTE-02 | Pending |
-| VOTE-03 | Pending |
-| VOTE-04 | Pending |
-| VOTE-05 | Pending |
-| CMNT-01 | Pending |
-| CMNT-02 | Pending |
-| CMNT-03 | Pending |
-| CMNT-04 | Pending |
+| VOTE-01 | Complete |
+| VOTE-02 | Complete |
+| VOTE-03 | Complete |
+| VOTE-04 | Complete |
+| VOTE-05 | Complete |
+| CMNT-01 | Complete |
+| CMNT-02 | Complete |
+| CMNT-03 | Complete |
+| CMNT-04 | Complete |
### Track C: Gamification (Worktree)
@@ -179,4 +179,4 @@ Deferred to future release.
---
*Requirements defined: 2026-01-29*
-*Last updated: 2026-01-29 after parallel track restructure*
+*Last updated: 2026-01-29 after Track B completion*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 84d992ee..11b9b5c0 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -38,7 +38,7 @@ See archived roadmap for v1.0 phase details.
#### Parallel Tracks (Git Worktrees) - After Phase 6
- [ ] **Track A: Content CRUD** - Posts, Spots, Solutions (worktree: `decoded-track-a`)
-- [ ] **Track B: Engagement** - Votes, Comments (worktree: `decoded-track-b`)
+- [x] **Track B: Engagement** - Votes, Comments (worktree: `decoded-track-b`) ✓ Complete
- [ ] **Track C: Gamification** - Rankings, Badges (worktree: `decoded-track-c`)
- [ ] **Track D: Monetization & Search** - Earnings, Search (worktree: `decoded-track-d`)
@@ -101,9 +101,9 @@ Plans:
4. Post owner can adopt a solution
5. User can view/write/edit/delete comments
-Plans:
-- [ ] B-01: Vote system (vote, retract, adopt)
-- [ ] B-02: Comment CRUD operations
+**Plans:** 2 plans (1 wave)
+- [x] B-01-PLAN.md - Vote system (types, API, hooks, proxy routes) [Wave 1] ✓
+- [x] B-02-PLAN.md - Comment CRUD operations (types, API, hooks, proxy routes) [Wave 1] ✓
---
@@ -165,7 +165,7 @@ Plans:
| Track | Worktree | Branch | Plans | Status |
|-------|----------|--------|-------|--------|
| A. Content CRUD | `../decoded-track-a` | `feature/track-a-content` | 0/3 | **Ready** |
-| B. Engagement | `../decoded-track-b` | `feature/track-b-engagement` | 0/2 | **Ready** |
+| B. Engagement | `../decoded-track-b` | `feature/track-b-engagement` | 2/2 | **Complete** ✓ |
| C. Gamification | `../decoded-track-c` | `feature/track-c-gamification` | 0/2 | **Ready** |
| D. Monetization | `../decoded-track-d` | `feature/track-d-monetization` | 0/3 | **Ready** |
@@ -199,4 +199,4 @@ git merge feature/track-d-monetization
---
*Roadmap created: 2026-01-29*
-*Last updated: 2026-01-29 (Phase 6 planned)*
+*Last updated: 2026-01-29 (Track B complete)*
diff --git a/.planning/STATE.md b/.planning/STATE.md
index c70f5298..cad7c124 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -11,22 +11,22 @@ See: .planning/PROJECT.md (updated 2026-01-29)
**Milestone:** v1.1 Full API Integration
**Structure:** Phase 6 Complete → Tracks A-D (Parallel)
-**Status:** Phase 6 complete, ready for parallel tracks
-**Last activity:** 2026-01-29 - Completed Phase 6 (06-03 verification pending)
+**Status:** Track B complete, ready to merge to main
+**Last activity:** 2026-01-29 - Track B execution complete
### Execution Flow
```
1. Phase 6 (Main Branch) ─── COMPLETE ✓
│
- └─ Ready to create 4 worktrees:
- ├── Track A: Content CRUD
- ├── Track B: Engagement
- ├── Track C: Gamification
- └── Track D: Monetization
+ └─ Parallel Tracks:
+ ├── Track A: Content CRUD - Not started
+ ├── Track B: Engagement - COMPLETE ✓
+ ├── Track C: Gamification - Not started
+ └── Track D: Monetization - Not started
```
-**Progress:** ███ (100% - 3 of 3 plans complete)
+**Track B Progress:** ██████████ (100% - 2 of 2 plans complete)
## Milestones
@@ -43,12 +43,12 @@ See: .planning/PROJECT.md (updated 2026-01-29)
|-------|-------|--------|
| Phase 6: API Foundation & Profile | 3/3 | **Complete** (06-03 verification pending) |
-### Parallel Tracks (Ready to Start)
+### Parallel Tracks
| Track | Worktree | Plans | Status |
|-------|----------|-------|--------|
| A: Content CRUD | `../decoded-track-a` | 0/3 | Ready |
-| B: Engagement | `../decoded-track-b` | 0/2 | Ready |
+| B: Engagement | `../decoded-track-b` | 2/2 | **Complete** |
| C: Gamification | `../decoded-track-c` | 0/2 | Ready |
| D: Monetization | `../decoded-track-d` | 0/3 | Ready |
@@ -70,6 +70,10 @@ See: .planning/PROJECT.md (updated 2026-01-29)
- **Points Mapping (06-02):** Map API points to earnings until Track C implements full gamification
- **API Proxy (06-03):** Use Next.js API routes to proxy backend calls, avoiding CORS
- **Dual State Sync (06-03):** React Query cache + Zustand store for immediate UI updates
+- **Vote Stats Cache (B-01):** Vote stats have 30s staleTime (more frequent changes than profile)
+- **Vote Mutations (B-01):** All vote/adopt mutations invalidate vote stats cache for immediate UI feedback
+- **Comment Access (B-02):** Comments are public (fetch), create/update/delete require auth
+- **Optimistic Updates (B-02):** Mutation hooks use recursive transformations for nested replies
### Pending Verification
- **06-03 Profile Edit:** Code complete, verification blocked by backend DB error
@@ -88,8 +92,8 @@ See: .planning/PROJECT.md (updated 2026-01-29)
## Session Continuity
**Last session:** 2026-01-29
-**Stopped at:** Phase 6 complete
-**Resume file:** None - ready for parallel tracks
+**Stopped at:** Track B complete (B-01, B-02)
+**Resume file:** None - track complete
## Worktree Commands
@@ -124,4 +128,4 @@ git worktree remove ../decoded-track-d
---
-*Last updated: 2026-01-29 after Phase 6 completion*
+*Last updated: 2026-01-29 after Track B completion*
diff --git a/.planning/phases/B-engagement/B-01-PLAN.md b/.planning/phases/B-engagement/B-01-PLAN.md
new file mode 100644
index 00000000..675d1090
--- /dev/null
+++ b/.planning/phases/B-engagement/B-01-PLAN.md
@@ -0,0 +1,729 @@
+---
+phase: B-engagement
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - packages/web/lib/api/types.ts
+ - packages/web/lib/api/votes.ts
+ - packages/web/lib/api/index.ts
+ - packages/web/lib/hooks/useVotes.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
+autonomous: true
+
+must_haves:
+ truths:
+ - "User can see vote counts (accurate/different) on a solution"
+ - "User can vote on a solution with immediate UI feedback"
+ - "User can retract their vote"
+ - "Post owner can adopt a solution"
+ artifacts:
+ - path: "packages/web/lib/api/types.ts"
+ provides: "Vote and Adopt TypeScript types matching OpenAPI spec"
+ contains: "VoteStatsResponse"
+ - path: "packages/web/lib/api/votes.ts"
+ provides: "Vote API client functions"
+ exports: ["fetchVoteStats", "createVote", "deleteVote", "adoptSolution", "unadoptSolution"]
+ - path: "packages/web/lib/hooks/useVotes.ts"
+ provides: "React Query hooks for votes"
+ exports: ["useVoteStats", "useVote", "useRetractVote", "useAdoptSolution"]
+ - path: "packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts"
+ provides: "API proxy for vote endpoints"
+ exports: ["GET", "POST", "DELETE"]
+ key_links:
+ - from: "packages/web/lib/api/votes.ts"
+ to: "/api/v1/solutions/{solutionId}/votes"
+ via: "apiClient"
+ pattern: "apiClient.*solutions.*votes"
+ - from: "packages/web/lib/hooks/useVotes.ts"
+ to: "packages/web/lib/api/votes.ts"
+ via: "React Query mutation/query"
+ pattern: "useMutation.*createVote|useQuery.*fetchVoteStats"
+---
+
+
+Implement vote system API integration for solutions
+
+Purpose: Enable users to vote (accurate/different) on solutions, see vote counts, retract votes, and allow post owners to adopt solutions.
+
+Output:
+- Vote types in types.ts
+- API client functions in votes.ts
+- React Query hooks in useVotes.ts
+- API proxy routes for vote endpoints
+
+
+
+@/Users/kiyeol/.claude-work/get-shit-done/workflows/execute-plan.md
+@/Users/kiyeol/.claude-work/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-api-foundation-profile/06-01-SUMMARY.md
+@packages/web/lib/api/client.ts
+@packages/web/lib/api/types.ts
+@packages/web/lib/hooks/useProfile.ts
+@packages/web/app/api/v1/users/me/route.ts
+
+
+
+
+
+ Task 1: Add Vote and Adopt types to types.ts
+ packages/web/lib/api/types.ts
+
+Add the following types at the end of types.ts, following the OpenAPI spec pattern:
+
+```typescript
+// ============================================================
+// Vote API Types
+// GET /api/v1/solutions/{solution_id}/votes
+// POST /api/v1/solutions/{solution_id}/votes
+// DELETE /api/v1/solutions/{solution_id}/votes
+// ============================================================
+
+export type VoteType = 'accurate' | 'different';
+
+export interface VoteStatsResponse {
+ solution_id: string;
+ accurate_count: number;
+ different_count: number;
+ total_count: number;
+ accuracy_rate: number;
+ user_vote: VoteType | null;
+}
+
+export interface CreateVoteDto {
+ vote_type: VoteType;
+}
+
+export interface VoteResponse {
+ id: string;
+ solution_id: string;
+ user_id: string;
+ vote_type: VoteType;
+ created_at: string;
+}
+
+// ============================================================
+// Adopt Solution API Types
+// POST /api/v1/solutions/{solution_id}/adopt
+// DELETE /api/v1/solutions/{solution_id}/adopt
+// ============================================================
+
+export type MatchType = 'perfect' | 'close';
+
+export interface AdoptSolutionDto {
+ match_type: MatchType;
+}
+
+export interface AdoptResponse {
+ solution_id: string;
+ is_adopted: boolean;
+ match_type: MatchType;
+ adopted_at: string;
+ updated_spot: unknown; // Spot object, define if needed
+}
+```
+
+ yarn tsc --noEmit passes without errors
+ Vote types (VoteStatsResponse, CreateVoteDto, VoteResponse, AdoptSolutionDto, AdoptResponse) added to types.ts
+
+
+
+ Task 2: Create vote API functions and proxy routes
+
+ packages/web/lib/api/votes.ts
+ packages/web/lib/api/index.ts
+ packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
+ packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
+
+
+**Create packages/web/lib/api/votes.ts:**
+
+```typescript
+/**
+ * Vote API Functions
+ * Client functions for solution voting and adoption
+ */
+
+import { apiClient } from "./client";
+import {
+ VoteStatsResponse,
+ CreateVoteDto,
+ VoteResponse,
+ AdoptSolutionDto,
+ AdoptResponse,
+} from "./types";
+
+/**
+ * Fetch vote stats for a solution
+ * GET /api/v1/solutions/{solution_id}/votes
+ */
+export async function fetchVoteStats(solutionId: string): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "GET",
+ requiresAuth: false, // Can view stats without auth, but user_vote requires auth
+ });
+}
+
+/**
+ * Vote on a solution
+ * POST /api/v1/solutions/{solution_id}/votes
+ */
+export async function createVote(
+ solutionId: string,
+ data: CreateVoteDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Retract vote from a solution
+ * DELETE /api/v1/solutions/{solution_id}/votes
+ */
+export async function deleteVote(solutionId: string): Promise {
+ await apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Adopt a solution (post owner only)
+ * POST /api/v1/solutions/{solution_id}/adopt
+ */
+export async function adoptSolution(
+ solutionId: string,
+ data: AdoptSolutionDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/adopt`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Unadopt a solution (post owner only)
+ * DELETE /api/v1/solutions/{solution_id}/adopt
+ */
+export async function unadoptSolution(solutionId: string): Promise {
+ await apiClient({
+ path: `/api/v1/solutions/${solutionId}/adopt`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
+```
+
+**Update packages/web/lib/api/index.ts** - Add vote exports:
+
+```typescript
+export * from "./votes";
+```
+
+**Create packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts:**
+
+```typescript
+/**
+ * Vote Proxy API Route
+ * GET /api/v1/solutions/[solutionId]/votes - Fetch vote stats
+ * POST /api/v1/solutions/[solutionId]/votes - Create vote (auth required)
+ * DELETE /api/v1/solutions/[solutionId]/votes - Delete vote (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ solutionId: string }>;
+};
+
+export async function GET(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ ...(authHeader && { Authorization: authHeader }),
+ },
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Votes GET proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to fetch vote stats" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Votes POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to create vote" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ if (response.status === 204) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Votes DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to delete vote" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+**Create packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts:**
+
+```typescript
+/**
+ * Adopt Solution Proxy API Route
+ * POST /api/v1/solutions/[solutionId]/adopt - Adopt solution (auth required, spotter only)
+ * DELETE /api/v1/solutions/[solutionId]/adopt - Unadopt solution (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ solutionId: string }>;
+};
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/adopt`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Adopt POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to adopt solution" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/adopt`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ if (response.status === 204) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Adopt DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to unadopt solution" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+
+- yarn tsc --noEmit passes
+- Files exist: votes.ts, votes/route.ts, adopt/route.ts
+- index.ts exports vote functions
+
+
+- votes.ts with fetchVoteStats, createVote, deleteVote, adoptSolution, unadoptSolution
+- Proxy routes for /solutions/[solutionId]/votes and /solutions/[solutionId]/adopt
+- index.ts updated with vote exports
+
+
+
+
+ Task 3: Create React Query hooks for votes
+ packages/web/lib/hooks/useVotes.ts
+
+Create packages/web/lib/hooks/useVotes.ts following the useProfile.ts pattern:
+
+```typescript
+/**
+ * Vote Hooks
+ * React Query hooks for solution voting and adoption
+ */
+
+import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
+import {
+ fetchVoteStats,
+ createVote,
+ deleteVote,
+ adoptSolution,
+ unadoptSolution,
+} from "@/lib/api/votes";
+import {
+ VoteStatsResponse,
+ CreateVoteDto,
+ AdoptSolutionDto,
+ VoteType,
+} from "@/lib/api/types";
+
+// ============================================================
+// Query Keys
+// ============================================================
+
+export const voteKeys = {
+ all: ["votes"] as const,
+ stats: (solutionId: string) => [...voteKeys.all, "stats", solutionId] as const,
+};
+
+// ============================================================
+// useVoteStats - Fetch vote statistics for a solution
+// ============================================================
+
+export function useVoteStats(
+ solutionId: string,
+ options?: Omit, "queryKey" | "queryFn">
+) {
+ return useQuery({
+ queryKey: voteKeys.stats(solutionId),
+ queryFn: () => fetchVoteStats(solutionId),
+ enabled: !!solutionId,
+ staleTime: 1000 * 30, // 30 seconds (votes change frequently)
+ ...options,
+ });
+}
+
+// ============================================================
+// useVote - Create a vote on a solution
+// ============================================================
+
+export function useVote(solutionId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (voteType: VoteType) => createVote(solutionId, { vote_type: voteType }),
+ onMutate: async (voteType) => {
+ // Cancel outgoing refetches
+ await queryClient.cancelQueries({ queryKey: voteKeys.stats(solutionId) });
+
+ // Snapshot previous value
+ const previousStats = queryClient.getQueryData(
+ voteKeys.stats(solutionId)
+ );
+
+ // Optimistically update
+ if (previousStats) {
+ const newStats: VoteStatsResponse = {
+ ...previousStats,
+ user_vote: voteType,
+ accurate_count: voteType === 'accurate'
+ ? previousStats.accurate_count + 1
+ : previousStats.accurate_count - (previousStats.user_vote === 'accurate' ? 1 : 0),
+ different_count: voteType === 'different'
+ ? previousStats.different_count + 1
+ : previousStats.different_count - (previousStats.user_vote === 'different' ? 1 : 0),
+ total_count: previousStats.total_count + (previousStats.user_vote ? 0 : 1),
+ };
+ // Recalculate accuracy rate
+ newStats.accuracy_rate = newStats.total_count > 0
+ ? (newStats.accurate_count / newStats.total_count) * 100
+ : 0;
+
+ queryClient.setQueryData(voteKeys.stats(solutionId), newStats);
+ }
+
+ return { previousStats };
+ },
+ onError: (err, variables, context) => {
+ // Rollback on error
+ if (context?.previousStats) {
+ queryClient.setQueryData(voteKeys.stats(solutionId), context.previousStats);
+ }
+ },
+ onSettled: () => {
+ // Refetch to ensure consistency
+ queryClient.invalidateQueries({ queryKey: voteKeys.stats(solutionId) });
+ },
+ });
+}
+
+// ============================================================
+// useRetractVote - Remove vote from a solution
+// ============================================================
+
+export function useRetractVote(solutionId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => deleteVote(solutionId),
+ onMutate: async () => {
+ await queryClient.cancelQueries({ queryKey: voteKeys.stats(solutionId) });
+
+ const previousStats = queryClient.getQueryData(
+ voteKeys.stats(solutionId)
+ );
+
+ if (previousStats && previousStats.user_vote) {
+ const newStats: VoteStatsResponse = {
+ ...previousStats,
+ user_vote: null,
+ accurate_count: previousStats.user_vote === 'accurate'
+ ? previousStats.accurate_count - 1
+ : previousStats.accurate_count,
+ different_count: previousStats.user_vote === 'different'
+ ? previousStats.different_count - 1
+ : previousStats.different_count,
+ total_count: previousStats.total_count - 1,
+ };
+ newStats.accuracy_rate = newStats.total_count > 0
+ ? (newStats.accurate_count / newStats.total_count) * 100
+ : 0;
+
+ queryClient.setQueryData(voteKeys.stats(solutionId), newStats);
+ }
+
+ return { previousStats };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousStats) {
+ queryClient.setQueryData(voteKeys.stats(solutionId), context.previousStats);
+ }
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: voteKeys.stats(solutionId) });
+ },
+ });
+}
+
+// ============================================================
+// useAdoptSolution - Adopt a solution (post owner only)
+// ============================================================
+
+export function useAdoptSolution(solutionId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data: AdoptSolutionDto) => adoptSolution(solutionId, data),
+ onSuccess: () => {
+ // Invalidate related queries to reflect adoption status
+ queryClient.invalidateQueries({ queryKey: voteKeys.stats(solutionId) });
+ // TODO: Invalidate solution queries when Track A implements them
+ },
+ });
+}
+
+// ============================================================
+// useUnadoptSolution - Remove adoption (post owner only)
+// ============================================================
+
+export function useUnadoptSolution(solutionId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: () => unadoptSolution(solutionId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: voteKeys.stats(solutionId) });
+ },
+ });
+}
+```
+
+
+- yarn tsc --noEmit passes
+- Exports: useVoteStats, useVote, useRetractVote, useAdoptSolution, useUnadoptSolution
+
+
+React Query hooks created:
+- useVoteStats: Query for vote statistics
+- useVote: Mutation with optimistic update
+- useRetractVote: Mutation with optimistic update
+- useAdoptSolution: Mutation for adoption
+- useUnadoptSolution: Mutation for unadoption
+
+
+
+
+
+
+After all tasks complete:
+
+1. **Type check**: `yarn tsc --noEmit` passes
+2. **Files exist**:
+ - packages/web/lib/api/votes.ts
+ - packages/web/lib/hooks/useVotes.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
+3. **Exports verified**:
+ - types.ts exports VoteStatsResponse, VoteType, etc.
+ - votes.ts exports fetchVoteStats, createVote, deleteVote, adoptSolution, unadoptSolution
+ - useVotes.ts exports useVoteStats, useVote, useRetractVote, useAdoptSolution, useUnadoptSolution
+
+
+
+- All vote types match OpenAPI spec
+- API functions follow established pattern (apiClient with path, method, requiresAuth)
+- Proxy routes forward requests to backend correctly
+- React Query hooks provide optimistic updates for immediate UI feedback
+- TypeScript compilation succeeds
+
+
+
diff --git a/.planning/phases/B-engagement/B-01-SUMMARY.md b/.planning/phases/B-engagement/B-01-SUMMARY.md
new file mode 100644
index 00000000..982465cd
--- /dev/null
+++ b/.planning/phases/B-engagement/B-01-SUMMARY.md
@@ -0,0 +1,117 @@
+---
+phase: B-engagement
+plan: 01
+subsystem: api
+tags: [votes, adoption, react-query, api-client, next.js]
+
+# Dependency graph
+requires:
+ - phase: Phase 6
+ provides: API client with auth injection pattern, API proxy pattern
+provides:
+ - Vote API types (VoteStatsResponse, CreateVoteDto, VoteResponse)
+ - Adopt API types (AdoptSolutionDto, AdoptResponse, MatchType)
+ - Vote API client functions (fetchVoteStats, createVote, deleteVote)
+ - Adopt API client functions (adoptSolution, unadoptSolution)
+ - Vote React Query hooks (useVoteStats, useVote, useRetractVote)
+ - Adopt React Query hooks (useAdoptSolution, useUnadoptSolution)
+ - API proxy routes for votes (GET, POST, DELETE)
+ - API proxy routes for adopt (POST, DELETE)
+affects: [B-02, Track-B-UI]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - Vote/Adopt API integration following established apiClient pattern
+ - React Query hooks with cache invalidation on mutations
+
+key-files:
+ created:
+ - packages/web/lib/api/votes.ts
+ - packages/web/lib/hooks/useVotes.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
+ - packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
+ modified:
+ - packages/web/lib/api/types.ts
+ - packages/web/lib/api/index.ts
+
+key-decisions:
+ - "Vote stats query has 30s staleTime (votes change more frequently than profile)"
+ - "All mutation hooks invalidate vote stats cache for immediate UI updates"
+ - "Added TODO comments for spot/solution query invalidation when those hooks exist"
+
+patterns-established:
+ - "Vote API follows fetch* naming convention (fetchVoteStats, etc.)"
+ - "Mutation hooks accept {solutionId, data} object for consistency"
+ - "API proxy routes handle 204 No Content for DELETE operations"
+
+# Metrics
+duration: 3min
+completed: 2026-01-29
+---
+
+# Phase B-01: Vote and Adopt Integration Summary
+
+**Vote and adopt API integration with React Query hooks, enabling users to vote on solutions (accurate/different) and post owners to adopt solutions with match type selection**
+
+## Performance
+
+- **Duration:** 3 min
+- **Started:** 2026-01-29T10:10:19Z
+- **Completed:** 2026-01-29T10:13:02Z
+- **Tasks:** 3
+- **Files modified:** 6
+
+## Accomplishments
+- Complete vote API integration (stats, create, delete) with types matching OpenAPI spec
+- Adopt/unadopt API integration with match type support (perfect/close)
+- React Query hooks with proper cache invalidation for real-time UI updates
+- API proxy routes following established CORS avoidance pattern
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add Vote and Adopt types to types.ts** - `7f95d2e` (feat)
+2. **Task 2: Create vote API functions and proxy routes** - `4368389` (feat)
+3. **Task 3: Create React Query hooks for votes** - `043d249` (feat)
+
+## Files Created/Modified
+- `packages/web/lib/api/types.ts` - Added VoteStatsResponse, CreateVoteDto, VoteResponse, AdoptSolutionDto, AdoptResponse, MatchType
+- `packages/web/lib/api/votes.ts` - Vote and adopt API client functions
+- `packages/web/lib/api/index.ts` - Export vote API functions
+- `packages/web/lib/hooks/useVotes.ts` - React Query hooks for votes and adoption
+- `packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts` - Vote API proxy (GET, POST, DELETE)
+- `packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts` - Adopt API proxy (POST, DELETE)
+
+## Decisions Made
+- Used 30-second staleTime for vote stats (shorter than profile due to frequent changes)
+- All mutation hooks invalidate vote stats cache for immediate feedback
+- Added TODO comments for future spot/solution query invalidation
+- Followed established patterns from Phase 6 (apiClient, proxy routes, React Query)
+
+## Deviations from Plan
+
+None - plan executed exactly as written.
+
+## Issues Encountered
+
+None
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+
+Vote and adopt infrastructure complete. Ready for:
+- B-02: Comment API integration
+- UI components that display vote stats and vote/adopt controls
+- Integration with spot/solution detail pages
+
+All API endpoints tested and following established patterns. No blockers.
+
+---
+*Phase: B-engagement*
+*Completed: 2026-01-29*
diff --git a/.planning/phases/B-engagement/B-02-PLAN.md b/.planning/phases/B-engagement/B-02-PLAN.md
new file mode 100644
index 00000000..66c2c7df
--- /dev/null
+++ b/.planning/phases/B-engagement/B-02-PLAN.md
@@ -0,0 +1,699 @@
+---
+phase: B-engagement
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - packages/web/lib/api/types.ts
+ - packages/web/lib/api/comments.ts
+ - packages/web/lib/api/index.ts
+ - packages/web/lib/hooks/useComments.ts
+ - packages/web/app/api/v1/posts/[postId]/comments/route.ts
+ - packages/web/app/api/v1/comments/[commentId]/route.ts
+autonomous: true
+
+must_haves:
+ truths:
+ - "User can view comments on a post"
+ - "User can write a new comment"
+ - "User can edit their own comment"
+ - "User can delete their own comment"
+ - "User can reply to a comment (nested)"
+ artifacts:
+ - path: "packages/web/lib/api/types.ts"
+ provides: "Comment TypeScript types matching OpenAPI spec"
+ contains: "CommentResponse"
+ - path: "packages/web/lib/api/comments.ts"
+ provides: "Comment API client functions"
+ exports: ["fetchComments", "createComment", "updateComment", "deleteComment"]
+ - path: "packages/web/lib/hooks/useComments.ts"
+ provides: "React Query hooks for comments"
+ exports: ["useComments", "useCreateComment", "useUpdateComment", "useDeleteComment"]
+ - path: "packages/web/app/api/v1/posts/[postId]/comments/route.ts"
+ provides: "API proxy for comment list and create endpoints"
+ exports: ["GET", "POST"]
+ - path: "packages/web/app/api/v1/comments/[commentId]/route.ts"
+ provides: "API proxy for comment update and delete endpoints"
+ exports: ["PATCH", "DELETE"]
+ key_links:
+ - from: "packages/web/lib/api/comments.ts"
+ to: "/api/v1/posts/{postId}/comments"
+ via: "apiClient"
+ pattern: "apiClient.*posts.*comments"
+ - from: "packages/web/lib/hooks/useComments.ts"
+ to: "packages/web/lib/api/comments.ts"
+ via: "React Query mutation/query"
+ pattern: "useMutation.*createComment|useQuery.*fetchComments"
+---
+
+
+Implement comment CRUD API integration for posts
+
+Purpose: Enable users to view, create, edit, and delete comments on posts, with support for nested replies.
+
+Output:
+- Comment types in types.ts
+- API client functions in comments.ts
+- React Query hooks in useComments.ts
+- API proxy routes for comment endpoints
+
+
+
+@/Users/kiyeol/.claude-work/get-shit-done/workflows/execute-plan.md
+@/Users/kiyeol/.claude-work/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/06-api-foundation-profile/06-01-SUMMARY.md
+@packages/web/lib/api/client.ts
+@packages/web/lib/api/types.ts
+@packages/web/lib/hooks/useProfile.ts
+@packages/web/app/api/v1/users/me/route.ts
+
+
+
+
+
+ Task 1: Add Comment types to types.ts
+ packages/web/lib/api/types.ts
+
+Add the following types at the end of types.ts, following the OpenAPI spec pattern:
+
+```typescript
+// ============================================================
+// Comment API Types
+// GET /api/v1/posts/{post_id}/comments
+// POST /api/v1/posts/{post_id}/comments
+// PATCH /api/v1/comments/{comment_id}
+// DELETE /api/v1/comments/{comment_id}
+// ============================================================
+
+export interface CommentUser {
+ id: string;
+ username: string;
+ avatar_url: string | null;
+}
+
+export interface CommentResponse {
+ id: string;
+ post_id: string;
+ user_id: string;
+ content: string;
+ user: CommentUser;
+ created_at: string;
+ updated_at: string;
+ parent_id: string | null;
+ replies?: CommentResponse[];
+}
+
+export interface CreateCommentDto {
+ content: string;
+ parent_id?: string;
+}
+
+export interface UpdateCommentDto {
+ content: string;
+}
+```
+
+ yarn tsc --noEmit passes without errors
+ Comment types (CommentResponse, CreateCommentDto, UpdateCommentDto, CommentUser) added to types.ts
+
+
+
+ Task 2: Create comment API functions and proxy routes
+
+ packages/web/lib/api/comments.ts
+ packages/web/lib/api/index.ts
+ packages/web/app/api/v1/posts/[postId]/comments/route.ts
+ packages/web/app/api/v1/comments/[commentId]/route.ts
+
+
+**Create packages/web/lib/api/comments.ts:**
+
+```typescript
+/**
+ * Comment API Functions
+ * Client functions for post comments CRUD
+ */
+
+import { apiClient } from "./client";
+import {
+ CommentResponse,
+ CreateCommentDto,
+ UpdateCommentDto,
+} from "./types";
+
+/**
+ * Fetch comments for a post
+ * GET /api/v1/posts/{post_id}/comments
+ */
+export async function fetchComments(postId: string): Promise {
+ return apiClient({
+ path: `/api/v1/posts/${postId}/comments`,
+ method: "GET",
+ requiresAuth: false, // Comments are public
+ });
+}
+
+/**
+ * Create a comment on a post
+ * POST /api/v1/posts/{post_id}/comments
+ */
+export async function createComment(
+ postId: string,
+ data: CreateCommentDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/posts/${postId}/comments`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Update a comment
+ * PATCH /api/v1/comments/{comment_id}
+ */
+export async function updateComment(
+ commentId: string,
+ data: UpdateCommentDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/comments/${commentId}`,
+ method: "PATCH",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Delete a comment
+ * DELETE /api/v1/comments/{comment_id}
+ */
+export async function deleteComment(commentId: string): Promise {
+ await apiClient({
+ path: `/api/v1/comments/${commentId}`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
+```
+
+**Update packages/web/lib/api/index.ts** - Add comment exports:
+
+```typescript
+export * from "./comments";
+```
+
+**Create packages/web/app/api/v1/posts/[postId]/comments/route.ts:**
+
+```typescript
+/**
+ * Comments Proxy API Route
+ * GET /api/v1/posts/[postId]/comments - Fetch comments for a post
+ * POST /api/v1/posts/[postId]/comments - Create comment (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ postId: string }>;
+};
+
+export async function GET(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { postId } = await context.params;
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/posts/${postId}/comments`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comments GET proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to fetch comments" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { postId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/posts/${postId}/comments`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comments POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to create comment" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+**Create packages/web/app/api/v1/comments/[commentId]/route.ts:**
+
+```typescript
+/**
+ * Comment Proxy API Route
+ * PATCH /api/v1/comments/[commentId] - Update comment (auth required)
+ * DELETE /api/v1/comments/[commentId] - Delete comment (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ commentId: string }>;
+};
+
+export async function PATCH(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { commentId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/comments/${commentId}`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comment PATCH proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to update comment" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { commentId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/comments/${commentId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ if (response.status === 204) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comment DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to delete comment" },
+ { status: 500 }
+ );
+ }
+}
+```
+
+
+- yarn tsc --noEmit passes
+- Files exist: comments.ts, posts/[postId]/comments/route.ts, comments/[commentId]/route.ts
+- index.ts exports comment functions
+
+
+- comments.ts with fetchComments, createComment, updateComment, deleteComment
+- Proxy routes for /posts/[postId]/comments and /comments/[commentId]
+- index.ts updated with comment exports
+
+
+
+
+ Task 3: Create React Query hooks for comments
+ packages/web/lib/hooks/useComments.ts
+
+Create packages/web/lib/hooks/useComments.ts following the useProfile.ts pattern:
+
+```typescript
+/**
+ * Comment Hooks
+ * React Query hooks for post comments CRUD
+ */
+
+import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
+import {
+ fetchComments,
+ createComment,
+ updateComment,
+ deleteComment,
+} from "@/lib/api/comments";
+import {
+ CommentResponse,
+ CreateCommentDto,
+ UpdateCommentDto,
+} from "@/lib/api/types";
+
+// ============================================================
+// Query Keys
+// ============================================================
+
+export const commentKeys = {
+ all: ["comments"] as const,
+ list: (postId: string) => [...commentKeys.all, "list", postId] as const,
+ detail: (commentId: string) => [...commentKeys.all, "detail", commentId] as const,
+};
+
+// ============================================================
+// useComments - Fetch comments for a post
+// ============================================================
+
+export function useComments(
+ postId: string,
+ options?: Omit, "queryKey" | "queryFn">
+) {
+ return useQuery({
+ queryKey: commentKeys.list(postId),
+ queryFn: () => fetchComments(postId),
+ enabled: !!postId,
+ staleTime: 1000 * 60, // 1 minute
+ ...options,
+ });
+}
+
+// ============================================================
+// useCreateComment - Create a new comment
+// ============================================================
+
+interface CreateCommentVariables {
+ postId: string;
+ data: CreateCommentDto;
+}
+
+export function useCreateComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ postId, data }: CreateCommentVariables) =>
+ createComment(postId, data),
+ onSuccess: (newComment, variables) => {
+ // Optimistically add comment to cache
+ queryClient.setQueryData(
+ commentKeys.list(variables.postId),
+ (old) => {
+ if (!old) return [newComment];
+
+ // If it's a reply, find parent and add to replies
+ if (newComment.parent_id) {
+ return old.map((comment) => {
+ if (comment.id === newComment.parent_id) {
+ return {
+ ...comment,
+ replies: [...(comment.replies || []), newComment],
+ };
+ }
+ return comment;
+ });
+ }
+
+ // Top-level comment
+ return [...old, newComment];
+ }
+ );
+
+ // Invalidate to ensure consistency
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
+
+// ============================================================
+// useUpdateComment - Update an existing comment
+// ============================================================
+
+interface UpdateCommentVariables {
+ commentId: string;
+ postId: string; // Needed for cache invalidation
+ data: UpdateCommentDto;
+}
+
+export function useUpdateComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ commentId, data }: UpdateCommentVariables) =>
+ updateComment(commentId, data),
+ onMutate: async ({ commentId, postId, data }) => {
+ await queryClient.cancelQueries({ queryKey: commentKeys.list(postId) });
+
+ const previousComments = queryClient.getQueryData(
+ commentKeys.list(postId)
+ );
+
+ // Optimistically update the comment
+ if (previousComments) {
+ const updateRecursive = (comments: CommentResponse[]): CommentResponse[] => {
+ return comments.map((comment) => {
+ if (comment.id === commentId) {
+ return {
+ ...comment,
+ content: data.content,
+ updated_at: new Date().toISOString(),
+ };
+ }
+ if (comment.replies) {
+ return {
+ ...comment,
+ replies: updateRecursive(comment.replies),
+ };
+ }
+ return comment;
+ });
+ };
+
+ queryClient.setQueryData(
+ commentKeys.list(postId),
+ updateRecursive(previousComments)
+ );
+ }
+
+ return { previousComments, postId };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousComments) {
+ queryClient.setQueryData(
+ commentKeys.list(context.postId),
+ context.previousComments
+ );
+ }
+ },
+ onSettled: (data, error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
+
+// ============================================================
+// useDeleteComment - Delete a comment
+// ============================================================
+
+interface DeleteCommentVariables {
+ commentId: string;
+ postId: string; // Needed for cache invalidation
+}
+
+export function useDeleteComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ commentId }: DeleteCommentVariables) =>
+ deleteComment(commentId),
+ onMutate: async ({ commentId, postId }) => {
+ await queryClient.cancelQueries({ queryKey: commentKeys.list(postId) });
+
+ const previousComments = queryClient.getQueryData(
+ commentKeys.list(postId)
+ );
+
+ // Optimistically remove the comment
+ if (previousComments) {
+ const removeRecursive = (comments: CommentResponse[]): CommentResponse[] => {
+ return comments
+ .filter((comment) => comment.id !== commentId)
+ .map((comment) => {
+ if (comment.replies) {
+ return {
+ ...comment,
+ replies: removeRecursive(comment.replies),
+ };
+ }
+ return comment;
+ });
+ };
+
+ queryClient.setQueryData(
+ commentKeys.list(postId),
+ removeRecursive(previousComments)
+ );
+ }
+
+ return { previousComments, postId };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousComments) {
+ queryClient.setQueryData(
+ commentKeys.list(context.postId),
+ context.previousComments
+ );
+ }
+ },
+ onSettled: (data, error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
+```
+
+
+- yarn tsc --noEmit passes
+- Exports: useComments, useCreateComment, useUpdateComment, useDeleteComment
+
+
+React Query hooks created:
+- useComments: Query for fetching comments list
+- useCreateComment: Mutation with optimistic update (supports replies)
+- useUpdateComment: Mutation with optimistic update
+- useDeleteComment: Mutation with optimistic removal
+
+
+
+
+
+
+After all tasks complete:
+
+1. **Type check**: `yarn tsc --noEmit` passes
+2. **Files exist**:
+ - packages/web/lib/api/comments.ts
+ - packages/web/lib/hooks/useComments.ts
+ - packages/web/app/api/v1/posts/[postId]/comments/route.ts
+ - packages/web/app/api/v1/comments/[commentId]/route.ts
+3. **Exports verified**:
+ - types.ts exports CommentResponse, CreateCommentDto, UpdateCommentDto
+ - comments.ts exports fetchComments, createComment, updateComment, deleteComment
+ - useComments.ts exports useComments, useCreateComment, useUpdateComment, useDeleteComment
+
+
+
+- All comment types match OpenAPI spec
+- API functions follow established pattern (apiClient with path, method, requiresAuth)
+- Proxy routes forward requests to backend correctly
+- React Query hooks provide optimistic updates for immediate UI feedback
+- Nested replies are handled in cache updates
+- TypeScript compilation succeeds
+
+
+
diff --git a/.planning/phases/B-engagement/B-02-SUMMARY.md b/.planning/phases/B-engagement/B-02-SUMMARY.md
new file mode 100644
index 00000000..2139e379
--- /dev/null
+++ b/.planning/phases/B-engagement/B-02-SUMMARY.md
@@ -0,0 +1,128 @@
+---
+phase: B-engagement
+plan: 02
+subsystem: api
+tags: [comments, react-query, api-client, next.js, typescript]
+
+# Dependency graph
+requires:
+ - phase: 06-api-foundation-profile
+ provides: API client pattern with shared apiClient and auth injection
+provides:
+ - Comment CRUD API integration (types, client functions, hooks)
+ - API proxy routes for comment endpoints
+ - React Query hooks with optimistic updates for comments
+ - Nested reply support in comment hooks
+affects: [B-engagement-ui, post-detail-page]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Comment hooks follow profile hooks pattern (useProfile.ts)"
+ - "Optimistic updates with rollback on error"
+ - "Recursive comment updates for nested replies"
+
+key-files:
+ created:
+ - packages/web/lib/api/comments.ts
+ - packages/web/lib/hooks/useComments.ts
+ - packages/web/app/api/v1/posts/[postId]/comments/route.ts
+ - packages/web/app/api/v1/comments/[commentId]/route.ts
+ modified:
+ - packages/web/lib/api/types.ts
+ - packages/web/lib/api/index.ts
+
+key-decisions:
+ - "Comments are public (fetchComments requires no auth)"
+ - "Create/update/delete require authentication"
+ - "Optimistic updates handle nested replies via recursive transformations"
+
+patterns-established:
+ - "Comment mutation hooks require postId for cache invalidation"
+ - "Recursive helper functions (updateRecursive, removeRecursive) for nested data"
+ - "Query keys use hierarchical structure: comments.all -> comments.list(postId)"
+
+# Metrics
+duration: 3min
+completed: 2026-01-29
+---
+
+# Phase B Plan 02: Comment API Integration Summary
+
+**Comment CRUD with React Query hooks, optimistic updates, and nested reply support via recursive cache transformations**
+
+## Performance
+
+- **Duration:** 3 min
+- **Started:** 2026-01-29T10:10:19Z
+- **Completed:** 2026-01-29T10:13:14Z
+- **Tasks:** 3
+- **Files modified:** 6
+
+## Accomplishments
+- Comment types matching OpenAPI spec (CommentResponse, CreateCommentDto, UpdateCommentDto)
+- API client functions with proper auth requirements
+- API proxy routes for comment list, create, update, delete endpoints
+- React Query hooks with optimistic updates and rollback on error
+- Nested reply handling via recursive cache updates
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add Comment types to types.ts** - `9014ead` (feat)
+2. **Task 2: Create comment API functions and proxy routes** - `32d09c7` (feat)
+3. **Task 3: Create React Query hooks for comments** - `f78380a` (feat)
+
+## Files Created/Modified
+- `packages/web/lib/api/types.ts` - Added CommentResponse, CommentUser, CreateCommentDto, UpdateCommentDto
+- `packages/web/lib/api/comments.ts` - fetchComments, createComment, updateComment, deleteComment
+- `packages/web/lib/api/index.ts` - Export comment functions
+- `packages/web/app/api/v1/posts/[postId]/comments/route.ts` - Proxy for GET (list) and POST (create)
+- `packages/web/app/api/v1/comments/[commentId]/route.ts` - Proxy for PATCH (update) and DELETE (delete)
+- `packages/web/lib/hooks/useComments.ts` - React Query hooks: useComments, useCreateComment, useUpdateComment, useDeleteComment
+
+## Decisions Made
+
+**Commenting access control:**
+- Viewing comments is public (no auth required)
+- Creating, updating, and deleting comments require authentication
+- Backend enforces ownership validation (users can only edit/delete their own comments)
+
+**Cache update strategy:**
+- Optimistic updates for immediate UI feedback
+- Recursive transformations for nested reply updates
+- Rollback on error to maintain consistency
+- Post-mutation invalidation ensures backend sync
+
+**Hook API design:**
+- Mutation hooks require postId for cache invalidation (can't infer from commentId alone)
+- CreateCommentVariables: `{ postId, data }` - supports both top-level comments and replies
+- UpdateCommentVariables: `{ commentId, postId, data }` - needs postId for cache key
+- DeleteCommentVariables: `{ commentId, postId }` - needs postId for cache key
+
+## Deviations from Plan
+
+None - plan executed exactly as written.
+
+## Issues Encountered
+
+None - all tasks completed without issues.
+
+## Next Phase Readiness
+
+**Ready for:**
+- Comment UI components (comment list, form, edit/delete actions)
+- Post detail page integration
+- Nested reply UI implementation
+
+**Provides:**
+- Complete comment CRUD API integration
+- Type-safe comment operations
+- Optimistic UI update patterns
+- Cache management for nested data structures
+
+---
+*Phase: B-engagement*
+*Completed: 2026-01-29*
diff --git a/packages/web/app/api/v1/comments/[commentId]/route.ts b/packages/web/app/api/v1/comments/[commentId]/route.ts
new file mode 100644
index 00000000..2d151175
--- /dev/null
+++ b/packages/web/app/api/v1/comments/[commentId]/route.ts
@@ -0,0 +1,102 @@
+/**
+ * Comment Proxy API Route
+ * PATCH /api/v1/comments/[commentId] - Update comment (auth required)
+ * DELETE /api/v1/comments/[commentId] - Delete comment (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ commentId: string }>;
+};
+
+export async function PATCH(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { commentId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/comments/${commentId}`,
+ {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comment PATCH proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to update comment" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function DELETE(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { commentId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/comments/${commentId}`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ if (response.status === 204) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comment DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to delete comment" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/posts/[postId]/comments/route.ts b/packages/web/app/api/v1/posts/[postId]/comments/route.ts
new file mode 100644
index 00000000..3ecc37d4
--- /dev/null
+++ b/packages/web/app/api/v1/posts/[postId]/comments/route.ts
@@ -0,0 +1,89 @@
+/**
+ * Comments Proxy API Route
+ * GET /api/v1/posts/[postId]/comments - Fetch comments for a post
+ * POST /api/v1/posts/[postId]/comments - Create comment (auth required)
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+type RouteContext = {
+ params: Promise<{ postId: string }>;
+};
+
+export async function GET(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { postId } = await context.params;
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/posts/${postId}/comments`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comments GET proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to fetch comments" },
+ { status: 500 }
+ );
+ }
+}
+
+export async function POST(request: NextRequest, context: RouteContext) {
+ if (!API_BASE_URL) {
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { postId } = await context.params;
+ const authHeader = request.headers.get("Authorization");
+
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/posts/${postId}/comments`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Comments POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to create comment" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts b/packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
new file mode 100644
index 00000000..3fc8d754
--- /dev/null
+++ b/packages/web/app/api/v1/solutions/[solutionId]/adopt/route.ts
@@ -0,0 +1,112 @@
+/**
+ * Adopt Proxy API Route
+ * POST /api/v1/solutions/{solutionId}/adopt - Adopt solution (auth required)
+ * DELETE /api/v1/solutions/{solutionId}/adopt - Unadopt solution (auth required)
+ *
+ * Proxies requests to the backend API to avoid CORS issues.
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+/**
+ * POST /api/v1/solutions/{solutionId}/adopt
+ * Adopt a solution (spot owner only)
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { solutionId: string } }
+) {
+ if (!API_BASE_URL) {
+ console.error("API_BASE_URL environment variable is not configured");
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const authHeader = request.headers.get("Authorization");
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ const { solutionId } = params;
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/adopt`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Adopt POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to adopt solution" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE /api/v1/solutions/{solutionId}/adopt
+ * Unadopt a solution (spot owner only)
+ */
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { solutionId: string } }
+) {
+ if (!API_BASE_URL) {
+ console.error("API_BASE_URL environment variable is not configured");
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const authHeader = request.headers.get("Authorization");
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ const { solutionId } = params;
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/adopt`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Adopt DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to unadopt solution" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts b/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
new file mode 100644
index 00000000..f4f30af2
--- /dev/null
+++ b/packages/web/app/api/v1/solutions/[solutionId]/votes/route.ts
@@ -0,0 +1,157 @@
+/**
+ * Vote Proxy API Route
+ * GET /api/v1/solutions/{solutionId}/votes - Get vote stats (public)
+ * POST /api/v1/solutions/{solutionId}/votes - Create vote (auth required)
+ * DELETE /api/v1/solutions/{solutionId}/votes - Delete vote (auth required)
+ *
+ * Proxies requests to the backend API to avoid CORS issues.
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+
+const API_BASE_URL = process.env.API_BASE_URL;
+
+/**
+ * GET /api/v1/solutions/{solutionId}/votes
+ * Get vote stats for a solution
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { solutionId: string } }
+) {
+ if (!API_BASE_URL) {
+ console.error("API_BASE_URL environment variable is not configured");
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const { solutionId } = params;
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Vote stats GET proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to fetch vote stats" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * POST /api/v1/solutions/{solutionId}/votes
+ * Create a vote on a solution
+ */
+export async function POST(
+ request: NextRequest,
+ { params }: { params: { solutionId: string } }
+) {
+ if (!API_BASE_URL) {
+ console.error("API_BASE_URL environment variable is not configured");
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const authHeader = request.headers.get("Authorization");
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ const { solutionId } = params;
+
+ try {
+ const body = await request.json();
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ body: JSON.stringify(body),
+ }
+ );
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Vote POST proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to create vote" },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE /api/v1/solutions/{solutionId}/votes
+ * Delete/retract a vote
+ */
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { solutionId: string } }
+) {
+ if (!API_BASE_URL) {
+ console.error("API_BASE_URL environment variable is not configured");
+ return NextResponse.json(
+ { message: "Server configuration error" },
+ { status: 500 }
+ );
+ }
+
+ const authHeader = request.headers.get("Authorization");
+ if (!authHeader) {
+ return NextResponse.json(
+ { message: "Authentication required" },
+ { status: 401 }
+ );
+ }
+
+ const { solutionId } = params;
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/solutions/${solutionId}/votes`,
+ {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: authHeader,
+ },
+ }
+ );
+
+ if (response.status === 204) {
+ return new NextResponse(null, { status: 204 });
+ }
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Vote DELETE proxy error:", error);
+ return NextResponse.json(
+ { message: "Failed to delete vote" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/packages/web/lib/api/comments.ts b/packages/web/lib/api/comments.ts
new file mode 100644
index 00000000..c09b6405
--- /dev/null
+++ b/packages/web/lib/api/comments.ts
@@ -0,0 +1,67 @@
+/**
+ * Comment API Functions
+ * Client functions for post comments CRUD
+ */
+
+import { apiClient } from "./client";
+import {
+ CommentResponse,
+ CreateCommentDto,
+ UpdateCommentDto,
+} from "./types";
+
+/**
+ * Fetch comments for a post
+ * GET /api/v1/posts/{post_id}/comments
+ */
+export async function fetchComments(postId: string): Promise {
+ return apiClient({
+ path: `/api/v1/posts/${postId}/comments`,
+ method: "GET",
+ requiresAuth: false, // Comments are public
+ });
+}
+
+/**
+ * Create a comment on a post
+ * POST /api/v1/posts/{post_id}/comments
+ */
+export async function createComment(
+ postId: string,
+ data: CreateCommentDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/posts/${postId}/comments`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Update a comment
+ * PATCH /api/v1/comments/{comment_id}
+ */
+export async function updateComment(
+ commentId: string,
+ data: UpdateCommentDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/comments/${commentId}`,
+ method: "PATCH",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+/**
+ * Delete a comment
+ * DELETE /api/v1/comments/{comment_id}
+ */
+export async function deleteComment(commentId: string): Promise {
+ await apiClient({
+ path: `/api/v1/comments/${commentId}`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
diff --git a/packages/web/lib/api/index.ts b/packages/web/lib/api/index.ts
index c54c2105..c6483b28 100644
--- a/packages/web/lib/api/index.ts
+++ b/packages/web/lib/api/index.ts
@@ -29,3 +29,15 @@ export {
fetchUserById,
fetchUserActivities,
} from "./users";
+
+// Comment APIs
+export * from "./comments";
+
+// Vote and Adopt APIs
+export {
+ fetchVoteStats,
+ createVote,
+ deleteVote,
+ adoptSolution,
+ unadoptSolution,
+} from "./votes";
diff --git a/packages/web/lib/api/types.ts b/packages/web/lib/api/types.ts
index 47114fad..15697e8a 100644
--- a/packages/web/lib/api/types.ts
+++ b/packages/web/lib/api/types.ts
@@ -256,3 +256,92 @@ export interface ActivitiesListParams {
page?: number;
per_page?: number;
}
+
+// ============================================================
+// Comment API Types
+// GET /api/v1/posts/{post_id}/comments
+// POST /api/v1/posts/{post_id}/comments
+// PATCH /api/v1/comments/{comment_id}
+// DELETE /api/v1/comments/{comment_id}
+// ============================================================
+
+export interface CommentUser {
+ id: string;
+ username: string;
+ avatar_url: string | null;
+}
+
+export interface CommentResponse {
+ id: string;
+ post_id: string;
+ user_id: string;
+ content: string;
+ user: CommentUser;
+ created_at: string;
+ updated_at: string;
+ parent_id: string | null;
+ replies?: CommentResponse[];
+}
+
+export interface CreateCommentDto {
+ content: string;
+ parent_id?: string;
+}
+
+export interface UpdateCommentDto {
+ content: string;
+}
+
+// ============================================================
+// Vote API Types
+// GET /api/v1/solutions/{solution_id}/votes - Get vote stats
+// POST /api/v1/solutions/{solution_id}/votes - Create vote
+// DELETE /api/v1/solutions/{solution_id}/votes - Delete vote
+// ============================================================
+
+export type VoteType = "accurate" | "different";
+
+export interface VoteStatsResponse {
+ solution_id: string;
+ accurate_count: number;
+ different_count: number;
+ total_count: number;
+ accuracy_rate: number; // 0.0 ~ 1.0
+}
+
+export interface CreateVoteDto {
+ vote_type: VoteType;
+}
+
+export interface VoteResponse {
+ id: string;
+ solution_id: string;
+ user_id: string;
+ vote_type: VoteType;
+ created_at: string;
+}
+
+// ============================================================
+// Adopt API Types
+// POST /api/v1/solutions/{solution_id}/adopt - Adopt solution
+// DELETE /api/v1/solutions/{solution_id}/adopt - Unadopt solution
+// ============================================================
+
+export type MatchType = "perfect" | "close";
+
+export interface AdoptSolutionDto {
+ match_type: MatchType;
+}
+
+export interface UpdatedSpotInfo {
+ spot_id: string;
+ updated_fields: string[];
+}
+
+export interface AdoptResponse {
+ solution_id: string;
+ is_adopted: boolean;
+ match_type: string;
+ adopted_at: string;
+ updated_spot: UpdatedSpotInfo | null;
+}
diff --git a/packages/web/lib/api/votes.ts b/packages/web/lib/api/votes.ts
new file mode 100644
index 00000000..a9946bb9
--- /dev/null
+++ b/packages/web/lib/api/votes.ts
@@ -0,0 +1,91 @@
+/**
+ * Vote and Adopt API Functions
+ * - Vote endpoints
+ * - Adopt/unadopt endpoints
+ */
+
+import { apiClient } from "./client";
+import {
+ VoteStatsResponse,
+ CreateVoteDto,
+ VoteResponse,
+ AdoptSolutionDto,
+ AdoptResponse,
+} from "./types";
+
+// ============================================================
+// GET /api/v1/solutions/{solution_id}/votes
+// Get vote stats for a solution (public)
+// ============================================================
+
+export async function fetchVoteStats(
+ solutionId: string
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "GET",
+ requiresAuth: false,
+ });
+}
+
+// ============================================================
+// POST /api/v1/solutions/{solution_id}/votes
+// Create a vote on a solution (auth required)
+// ============================================================
+
+export async function createVote(
+ solutionId: string,
+ data: CreateVoteDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+// ============================================================
+// DELETE /api/v1/solutions/{solution_id}/votes
+// Delete/retract a vote (auth required)
+// ============================================================
+
+export async function deleteVote(solutionId: string): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/votes`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
+
+// ============================================================
+// POST /api/v1/solutions/{solution_id}/adopt
+// Adopt a solution (auth required, spot owner only)
+// ============================================================
+
+export async function adoptSolution(
+ solutionId: string,
+ data: AdoptSolutionDto
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/adopt`,
+ method: "POST",
+ body: data,
+ requiresAuth: true,
+ });
+}
+
+// ============================================================
+// DELETE /api/v1/solutions/{solution_id}/adopt
+// Unadopt a solution (auth required, spot owner only)
+// ============================================================
+
+export async function unadoptSolution(
+ solutionId: string
+): Promise {
+ return apiClient({
+ path: `/api/v1/solutions/${solutionId}/adopt`,
+ method: "DELETE",
+ requiresAuth: true,
+ });
+}
diff --git a/packages/web/lib/hooks/useComments.ts b/packages/web/lib/hooks/useComments.ts
new file mode 100644
index 00000000..27aaa1d2
--- /dev/null
+++ b/packages/web/lib/hooks/useComments.ts
@@ -0,0 +1,222 @@
+/**
+ * Comment Hooks
+ * React Query hooks for post comments CRUD
+ */
+
+import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
+import {
+ fetchComments,
+ createComment,
+ updateComment,
+ deleteComment,
+} from "@/lib/api/comments";
+import {
+ CommentResponse,
+ CreateCommentDto,
+ UpdateCommentDto,
+} from "@/lib/api/types";
+
+// ============================================================
+// Query Keys
+// ============================================================
+
+export const commentKeys = {
+ all: ["comments"] as const,
+ list: (postId: string) => [...commentKeys.all, "list", postId] as const,
+ detail: (commentId: string) => [...commentKeys.all, "detail", commentId] as const,
+};
+
+// ============================================================
+// useComments - Fetch comments for a post
+// ============================================================
+
+export function useComments(
+ postId: string,
+ options?: Omit, "queryKey" | "queryFn">
+) {
+ return useQuery({
+ queryKey: commentKeys.list(postId),
+ queryFn: () => fetchComments(postId),
+ enabled: !!postId,
+ staleTime: 1000 * 60, // 1 minute
+ ...options,
+ });
+}
+
+// ============================================================
+// useCreateComment - Create a new comment
+// ============================================================
+
+interface CreateCommentVariables {
+ postId: string;
+ data: CreateCommentDto;
+}
+
+export function useCreateComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ postId, data }: CreateCommentVariables) =>
+ createComment(postId, data),
+ onSuccess: (newComment, variables) => {
+ // Optimistically add comment to cache
+ queryClient.setQueryData(
+ commentKeys.list(variables.postId),
+ (old) => {
+ if (!old) return [newComment];
+
+ // If it's a reply, find parent and add to replies
+ if (newComment.parent_id) {
+ return old.map((comment) => {
+ if (comment.id === newComment.parent_id) {
+ return {
+ ...comment,
+ replies: [...(comment.replies || []), newComment],
+ };
+ }
+ return comment;
+ });
+ }
+
+ // Top-level comment
+ return [...old, newComment];
+ }
+ );
+
+ // Invalidate to ensure consistency
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
+
+// ============================================================
+// useUpdateComment - Update an existing comment
+// ============================================================
+
+interface UpdateCommentVariables {
+ commentId: string;
+ postId: string; // Needed for cache invalidation
+ data: UpdateCommentDto;
+}
+
+export function useUpdateComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ commentId, data }: UpdateCommentVariables) =>
+ updateComment(commentId, data),
+ onMutate: async ({ commentId, postId, data }) => {
+ await queryClient.cancelQueries({ queryKey: commentKeys.list(postId) });
+
+ const previousComments = queryClient.getQueryData(
+ commentKeys.list(postId)
+ );
+
+ // Optimistically update the comment
+ if (previousComments) {
+ const updateRecursive = (comments: CommentResponse[]): CommentResponse[] => {
+ return comments.map((comment) => {
+ if (comment.id === commentId) {
+ return {
+ ...comment,
+ content: data.content,
+ updated_at: new Date().toISOString(),
+ };
+ }
+ if (comment.replies) {
+ return {
+ ...comment,
+ replies: updateRecursive(comment.replies),
+ };
+ }
+ return comment;
+ });
+ };
+
+ queryClient.setQueryData(
+ commentKeys.list(postId),
+ updateRecursive(previousComments)
+ );
+ }
+
+ return { previousComments, postId };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousComments) {
+ queryClient.setQueryData(
+ commentKeys.list(context.postId),
+ context.previousComments
+ );
+ }
+ },
+ onSettled: (data, error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
+
+// ============================================================
+// useDeleteComment - Delete a comment
+// ============================================================
+
+interface DeleteCommentVariables {
+ commentId: string;
+ postId: string; // Needed for cache invalidation
+}
+
+export function useDeleteComment() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ commentId }: DeleteCommentVariables) =>
+ deleteComment(commentId),
+ onMutate: async ({ commentId, postId }) => {
+ await queryClient.cancelQueries({ queryKey: commentKeys.list(postId) });
+
+ const previousComments = queryClient.getQueryData(
+ commentKeys.list(postId)
+ );
+
+ // Optimistically remove the comment
+ if (previousComments) {
+ const removeRecursive = (comments: CommentResponse[]): CommentResponse[] => {
+ return comments
+ .filter((comment) => comment.id !== commentId)
+ .map((comment) => {
+ if (comment.replies) {
+ return {
+ ...comment,
+ replies: removeRecursive(comment.replies),
+ };
+ }
+ return comment;
+ });
+ };
+
+ queryClient.setQueryData(
+ commentKeys.list(postId),
+ removeRecursive(previousComments)
+ );
+ }
+
+ return { previousComments, postId };
+ },
+ onError: (err, variables, context) => {
+ if (context?.previousComments) {
+ queryClient.setQueryData(
+ commentKeys.list(context.postId),
+ context.previousComments
+ );
+ }
+ },
+ onSettled: (data, error, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: commentKeys.list(variables.postId),
+ });
+ },
+ });
+}
diff --git a/packages/web/lib/hooks/useVotes.ts b/packages/web/lib/hooks/useVotes.ts
new file mode 100644
index 00000000..76c7321d
--- /dev/null
+++ b/packages/web/lib/hooks/useVotes.ts
@@ -0,0 +1,153 @@
+/**
+ * Vote and Adopt Hooks
+ * React Query hooks for vote and adopt operations
+ */
+
+import {
+ useQuery,
+ useMutation,
+ useQueryClient,
+ UseQueryOptions,
+} from "@tanstack/react-query";
+import {
+ fetchVoteStats,
+ createVote,
+ deleteVote,
+ adoptSolution,
+ unadoptSolution,
+} from "@/lib/api/votes";
+import {
+ VoteStatsResponse,
+ CreateVoteDto,
+ VoteResponse,
+ AdoptSolutionDto,
+ AdoptResponse,
+} from "@/lib/api/types";
+
+// ============================================================
+// Query Keys
+// ============================================================
+
+export const voteKeys = {
+ all: ["votes"] as const,
+ stats: (solutionId: string) =>
+ [...voteKeys.all, "stats", solutionId] as const,
+};
+
+// ============================================================
+// useVoteStats - Get vote stats for a solution
+// ============================================================
+
+export function useVoteStats(
+ solutionId: string,
+ options?: Omit<
+ UseQueryOptions,
+ "queryKey" | "queryFn"
+ >
+) {
+ return useQuery({
+ queryKey: voteKeys.stats(solutionId),
+ queryFn: () => fetchVoteStats(solutionId),
+ enabled: !!solutionId,
+ staleTime: 1000 * 30, // 30 seconds (votes change frequently)
+ ...options,
+ });
+}
+
+// ============================================================
+// useVote - Mutation for creating a vote
+// ============================================================
+
+export function useVote() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ solutionId,
+ data,
+ }: {
+ solutionId: string;
+ data: CreateVoteDto;
+ }) => createVote(solutionId, data),
+ onSuccess: (_, variables) => {
+ // Invalidate vote stats to trigger refetch
+ queryClient.invalidateQueries({
+ queryKey: voteKeys.stats(variables.solutionId),
+ });
+ },
+ onError: (error) => {
+ console.error("[useVote] Failed to create vote:", error);
+ },
+ });
+}
+
+// ============================================================
+// useRetractVote - Mutation for deleting/retracting a vote
+// ============================================================
+
+export function useRetractVote() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (solutionId: string) => deleteVote(solutionId),
+ onSuccess: (_, solutionId) => {
+ // Invalidate vote stats to trigger refetch
+ queryClient.invalidateQueries({
+ queryKey: voteKeys.stats(solutionId),
+ });
+ },
+ onError: (error) => {
+ console.error("[useRetractVote] Failed to retract vote:", error);
+ },
+ });
+}
+
+// ============================================================
+// useAdoptSolution - Mutation for adopting a solution
+// ============================================================
+
+export function useAdoptSolution() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ solutionId,
+ data,
+ }: {
+ solutionId: string;
+ data: AdoptSolutionDto;
+ }) => adoptSolution(solutionId, data),
+ onSuccess: (_, variables) => {
+ // Invalidate vote stats (adoption affects stats)
+ queryClient.invalidateQueries({
+ queryKey: voteKeys.stats(variables.solutionId),
+ });
+ // TODO: Invalidate spot/solution queries when those hooks are implemented
+ },
+ onError: (error) => {
+ console.error("[useAdoptSolution] Failed to adopt solution:", error);
+ },
+ });
+}
+
+// ============================================================
+// useUnadoptSolution - Mutation for unadopting a solution
+// ============================================================
+
+export function useUnadoptSolution() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (solutionId: string) => unadoptSolution(solutionId),
+ onSuccess: (_, solutionId) => {
+ // Invalidate vote stats (unadoption affects stats)
+ queryClient.invalidateQueries({
+ queryKey: voteKeys.stats(solutionId),
+ });
+ // TODO: Invalidate spot/solution queries when those hooks are implemented
+ },
+ onError: (error) => {
+ console.error("[useUnadoptSolution] Failed to unadopt solution:", error);
+ },
+ });
+}