Implement all core features: Knowledge pipeline, RAG chat, Todos, Habits, Study Cards, Tags, Dashboard

- Google OAuth authentication with callback flow
- Knowledge ingest pipeline (TEXT/WEB/YOUTUBE → chunking → categorization → embedding)
- OCI GenAI integration (chat, embeddings) with multi-model support
- Semantic search via Oracle VECTOR_DISTANCE
- RAG-based AI chat with source attribution
- Todos with subtasks, filters, and priority levels
- Habits with daily check-in, streak tracking, and color customization
- Study Cards with SM-2 spaced repetition and LLM auto-generation
- Tags system with knowledge item mapping
- Dashboard with live data from all modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 21:43:51 +00:00
parent 3d2aa6cf46
commit 9929322de0
41 changed files with 11435 additions and 156 deletions

View File

@@ -17,6 +17,8 @@ module.exports = {
env: {
PORT: 3000,
HOSTNAME: "0.0.0.0",
NEXT_PUBLIC_API_URL: "https://sundol.cloud-handson.com",
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "906390686133-vpqsisodkg6uqui469hg8dhupbejoa0d.apps.googleusercontent.com",
},
},
],

View File

@@ -56,10 +56,12 @@
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>osdt_cert</artifactId>
<version>21.18.0.0</version>
</dependency>
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>osdt_core</artifactId>
<version>21.18.0.0</version>
</dependency>
<!-- JWT -->
@@ -81,6 +83,13 @@
<scope>runtime</scope>
</dependency>
<!-- Google Auth Library -->
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.30.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

View File

@@ -1,13 +1,19 @@
package com.sundol.controller;
import com.sundol.dto.LoginRequest;
import com.sundol.dto.LoginResponse;
import com.sundol.dto.RefreshRequest;
import com.sundol.service.AuthService;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@@ -19,19 +25,63 @@ public class AuthController {
}
@PostMapping("/google")
public Mono<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) {
return authService.googleLogin(request.idToken())
.map(ResponseEntity::ok);
public Mono<ResponseEntity<LoginResponse>> googleLogin(
@RequestBody Map<String, String> body,
ServerHttpResponse response) {
String code = body.get("code");
return authService.googleLogin(code)
.map(loginResponse -> {
// Set refresh token as HttpOnly cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(Duration.ofDays(30))
.sameSite("Strict")
.build();
response.addCookie(cookie);
return ResponseEntity.ok(loginResponse);
});
}
@PostMapping("/refresh")
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) {
return authService.refresh(request.refreshToken())
.map(ResponseEntity::ok);
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
String refreshToken = cookie != null ? cookie.getValue() : null;
if (refreshToken == null) {
return Mono.just(ResponseEntity.status(401).build());
}
return authService.refresh(refreshToken)
.map(loginResponse -> {
// Rotate cookie
ResponseCookie newCookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(Duration.ofDays(30))
.sameSite("Strict")
.build();
response.addCookie(newCookie);
return ResponseEntity.ok(loginResponse);
});
}
@PostMapping("/logout")
public Mono<ResponseEntity<Void>> logout(@RequestAttribute("userId") String userId) {
public Mono<ResponseEntity<Void>> logout(
@AuthenticationPrincipal String userId,
ServerHttpResponse response) {
// Clear cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(0)
.sameSite("Strict")
.build();
response.addCookie(cookie);
return authService.logout(userId)
.then(Mono.just(ResponseEntity.ok().<Void>build()));
}

View File

@@ -0,0 +1,30 @@
package com.sundol.controller;
import com.sundol.service.OciGenAiService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/models")
public class ModelController {
private final OciGenAiService genAiService;
public ModelController(OciGenAiService genAiService) {
this.genAiService = genAiService;
}
@GetMapping
public ResponseEntity<Map<String, Object>> listModels() {
return ResponseEntity.ok(Map.of(
"models", OciGenAiService.AVAILABLE_MODELS,
"defaultModel", genAiService.getDefaultModel(),
"configured", genAiService.isConfigured()
));
}
}

View File

@@ -1,3 +1,3 @@
package com.sundol.dto;
public record IngestRequest(String type, String url, String title, String rawText) {}
public record IngestRequest(String type, String url, String title, String rawText, String modelId) {}

View File

@@ -0,0 +1,122 @@
package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class CategoryRepository {
private final JdbcTemplate jdbcTemplate;
public CategoryRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* full_path로 카테고리 조회. 없으면 null.
*/
public Map<String, Object> findByUserAndPath(String userId, String fullPath) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
"FROM categories WHERE user_id = HEXTORAW(?) AND full_path = ?",
userId, fullPath
);
return results.isEmpty() ? null : results.get(0);
}
/**
* 카테고리 생성. 이미 존재하면 기존 ID 반환.
* full_path 예: "건강/운동/웨이트트레이닝"
*/
public String findOrCreate(String userId, String fullPath) {
Map<String, Object> existing = findByUserAndPath(userId, fullPath);
if (existing != null) {
return (String) existing.get("ID");
}
// 경로 파싱
String[] parts = fullPath.split("/");
int depth = parts.length;
String name = parts[parts.length - 1];
// 부모 카테고리 확보 (재귀)
String parentId = null;
if (depth > 1) {
String parentPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
parentId = findOrCreate(userId, parentPath);
}
// 삽입
if (parentId != null) {
jdbcTemplate.update(
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
userId, name, parentId, depth, fullPath
);
} else {
jdbcTemplate.update(
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, NULL, ?, ?, SYSTIMESTAMP)",
userId, name, depth, fullPath
);
}
Map<String, Object> created = findByUserAndPath(userId, fullPath);
return (String) created.get("ID");
}
/**
* knowledge_item에 카테고리 매핑
*/
public void linkCategory(String knowledgeItemId, String categoryId) {
// 중복 방지
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?) AND category_id = HEXTORAW(?)",
Integer.class, knowledgeItemId, categoryId
);
if (count != null && count > 0) return;
jdbcTemplate.update(
"INSERT INTO knowledge_item_categories (knowledge_item_id, category_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
knowledgeItemId, categoryId
);
}
/**
* knowledge_item의 카테고리 목록 조회
*/
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(c.id) AS id, c.name, c.depth, c.full_path " +
"FROM categories c " +
"JOIN knowledge_item_categories kic ON kic.category_id = c.id " +
"WHERE kic.knowledge_item_id = HEXTORAW(?) " +
"ORDER BY c.full_path",
knowledgeItemId
);
}
/**
* 사용자의 전체 카테고리 트리 조회
*/
public List<Map<String, Object>> findAllByUser(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
"FROM categories WHERE user_id = HEXTORAW(?) ORDER BY full_path",
userId
);
}
/**
* knowledge_item의 카테고리 매핑 전체 삭제
*/
public void unlinkAll(String knowledgeItemId) {
jdbcTemplate.update(
"DELETE FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?)",
knowledgeItemId
);
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class ChatRepository {
@@ -12,5 +15,75 @@ public class ChatRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for chat_sessions, chat_messages
public String createSession(String userId, String title) {
jdbcTemplate.update(
"INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, title
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM chat_sessions WHERE user_id = HEXTORAW(?) " +
"ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> listSessions(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
"FROM chat_sessions WHERE user_id = HEXTORAW(?) ORDER BY updated_at DESC",
userId
);
}
public Map<String, Object> findSession(String userId, String sessionId) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
"FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
sessionId, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateSessionTitle(String sessionId, String title) {
jdbcTemplate.update(
"UPDATE chat_sessions SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
title, sessionId
);
}
public void touchSession(String sessionId) {
jdbcTemplate.update(
"UPDATE chat_sessions SET updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
sessionId
);
}
public void deleteSession(String userId, String sessionId) {
jdbcTemplate.update(
"DELETE FROM chat_messages WHERE session_id = (SELECT id FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
sessionId, userId
);
jdbcTemplate.update(
"DELETE FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
sessionId, userId
);
}
public void insertMessage(String sessionId, String role, String content, String sourceChunksJson, Integer tokensUsed) {
jdbcTemplate.update(
"INSERT INTO chat_messages (id, session_id, role, content, source_chunks, tokens_used, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP)",
sessionId, role, content, sourceChunksJson, tokensUsed
);
}
public List<Map<String, Object>> getMessages(String sessionId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, role, content, source_chunks, tokens_used, created_at " +
"FROM chat_messages WHERE session_id = HEXTORAW(?) ORDER BY created_at ASC",
sessionId
);
}
}

View File

@@ -0,0 +1,87 @@
package com.sundol.repository;
import oracle.jdbc.OracleType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.util.List;
import java.util.Map;
@Repository
public class ChunkEmbeddingRepository {
private final JdbcTemplate jdbcTemplate;
public ChunkEmbeddingRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void upsertEmbedding(String chunkId, String modelId, float[] embedding) {
jdbcTemplate.update(
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?) AND model_id = ?",
chunkId, modelId
);
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO knowledge_chunk_embeddings (id, chunk_id, model_id, embedding, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)"
);
ps.setString(1, chunkId);
ps.setString(2, modelId);
ps.setObject(3, embedding, OracleType.VECTOR);
return ps;
});
}
public List<Map<String, Object>> findByChunkId(String chunkId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, model_id, created_at " +
"FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?)",
chunkId
);
}
/**
* 벡터 유사도 검색. 사용자의 knowledge 항목 중 READY 상태인 것만 대상.
*/
public List<Map<String, Object>> searchSimilar(String userId, float[] queryEmbedding, int topK) {
return jdbcTemplate.query(con -> {
PreparedStatement ps = con.prepareStatement(
"SELECT RAWTOHEX(c.id) AS chunk_id, c.content, c.chunk_index, c.token_count, " +
" RAWTOHEX(ki.id) AS knowledge_item_id, ki.title, ki.type, ki.source_url, " +
" VECTOR_DISTANCE(e.embedding, ?, COSINE) AS distance " +
"FROM knowledge_chunk_embeddings e " +
"JOIN knowledge_chunks c ON c.id = e.chunk_id " +
"JOIN knowledge_items ki ON ki.id = c.knowledge_item_id " +
"WHERE ki.user_id = HEXTORAW(?) AND ki.status = 'READY' " +
"ORDER BY distance ASC " +
"FETCH FIRST ? ROWS ONLY"
);
ps.setObject(1, queryEmbedding, OracleType.VECTOR);
ps.setString(2, userId);
ps.setInt(3, topK);
return ps;
}, (rs, rowNum) -> {
Map<String, Object> row = new java.util.HashMap<>();
row.put("CHUNK_ID", rs.getString("chunk_id"));
row.put("CONTENT", rs.getString("content"));
row.put("CHUNK_INDEX", rs.getInt("chunk_index"));
row.put("TOKEN_COUNT", rs.getInt("token_count"));
row.put("KNOWLEDGE_ITEM_ID", rs.getString("knowledge_item_id"));
row.put("TITLE", rs.getString("title"));
row.put("TYPE", rs.getString("type"));
row.put("SOURCE_URL", rs.getString("source_url"));
row.put("DISTANCE", rs.getDouble("distance"));
return row;
});
}
public void deleteByKnowledgeItemId(String knowledgeItemId) {
jdbcTemplate.update(
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id IN " +
"(SELECT id FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?))",
knowledgeItemId
);
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class HabitRepository {
@@ -12,5 +15,137 @@ public class HabitRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for habits, habit_logs
public String insert(String userId, String name, String description, String habitType, String targetDays, String color) {
jdbcTemplate.update(
"INSERT INTO habits (id, user_id, name, description, habit_type, target_days, color, streak_current, streak_best, is_active, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, ?, 0, 0, 1, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, name, description, habitType, targetDays, color
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM habits WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
"streak_current, streak_best, is_active, created_at, updated_at " +
"FROM habits WHERE user_id = HEXTORAW(?) AND is_active = 1 ORDER BY created_at ASC",
userId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
"streak_current, streak_best, is_active, created_at, updated_at " +
"FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateName(String id, String name) {
jdbcTemplate.update(
"UPDATE habits SET name = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
name, id
);
}
public void updateStreak(String id, int current, int best) {
jdbcTemplate.update(
"UPDATE habits SET streak_current = ?, streak_best = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
current, best, id
);
}
public void deactivate(String id) {
jdbcTemplate.update(
"UPDATE habits SET is_active = 0, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
id
);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM habit_logs WHERE habit_id = (SELECT id FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
id, userId
);
jdbcTemplate.update(
"DELETE FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
// --- Habit Logs ---
public void insertLog(String habitId, String note) {
jdbcTemplate.update(
"INSERT INTO habit_logs (id, habit_id, log_date, checked_in, note, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), TRUNC(SYSDATE), 1, ?, SYSTIMESTAMP)",
habitId, note
);
}
public boolean hasCheckedInToday(String habitId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM habit_logs WHERE habit_id = HEXTORAW(?) AND log_date = TRUNC(SYSDATE) AND checked_in = 1",
Integer.class, habitId
);
return count != null && count > 0;
}
public List<Map<String, Object>> getLogs(String habitId, String from, String to) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date, checked_in, note, created_at " +
"FROM habit_logs WHERE habit_id = HEXTORAW(?)"
);
java.util.ArrayList<Object> params = new java.util.ArrayList<>();
params.add(habitId);
if (from != null && !from.isEmpty()) {
sql.append(" AND log_date >= TO_DATE(?, 'YYYY-MM-DD')");
params.add(from);
}
if (to != null && !to.isEmpty()) {
sql.append(" AND log_date <= TO_DATE(?, 'YYYY-MM-DD')");
params.add(to);
}
sql.append(" ORDER BY log_date DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* 연속 체크인 일수 계산 (오늘부터 역순)
*/
public int calculateCurrentStreak(String habitId) {
List<Map<String, Object>> logs = jdbcTemplate.queryForList(
"SELECT TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date FROM habit_logs " +
"WHERE habit_id = HEXTORAW(?) AND checked_in = 1 ORDER BY log_date DESC",
habitId
);
if (logs.isEmpty()) return 0;
int streak = 0;
java.time.LocalDate expected = java.time.LocalDate.now();
for (Map<String, Object> log : logs) {
java.time.LocalDate logDate = java.time.LocalDate.parse((String) log.get("LOG_DATE"));
if (logDate.equals(expected)) {
streak++;
expected = expected.minusDays(1);
} else if (logDate.equals(expected.minusDays(1)) && streak == 0) {
// 어제까지 연속이면 (오늘 아직 안 한 경우)
expected = logDate;
streak++;
expected = expected.minusDays(1);
} else {
break;
}
}
return streak;
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class KnowledgeChunkRepository {
@@ -12,5 +15,34 @@ public class KnowledgeChunkRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for knowledge_chunks, VECTOR_DISTANCE search
public void insertChunk(String knowledgeItemId, int chunkIndex, String content, int tokenCount) {
jdbcTemplate.update(
"INSERT INTO knowledge_chunks (id, knowledge_item_id, chunk_index, content, token_count, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, SYSTIMESTAMP)",
knowledgeItemId, chunkIndex, content, tokenCount
);
}
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, chunk_index, content, token_count, created_at " +
"FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?) ORDER BY chunk_index",
knowledgeItemId
);
}
public int countByKnowledgeItemId(String knowledgeItemId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
Integer.class, knowledgeItemId
);
return count != null ? count : 0;
}
public void deleteByKnowledgeItemId(String knowledgeItemId) {
jdbcTemplate.update(
"DELETE FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
knowledgeItemId
);
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class KnowledgeRepository {
@@ -12,5 +15,80 @@ public class KnowledgeRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for knowledge_items table
public String insert(String userId, String type, String title, String sourceUrl, String rawText) {
jdbcTemplate.update(
"INSERT INTO knowledge_items (id, user_id, type, title, source_url, raw_text, status, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, 'PENDING', SYSTIMESTAMP, SYSTIMESTAMP)",
userId, type, title, sourceUrl, rawText
);
// Get the ID of the just-inserted row
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM knowledge_items WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)"
);
java.util.List<Object> params = new java.util.ArrayList<>();
params.add(userId);
if (type != null && !type.isEmpty()) {
sql.append(" AND type = ?");
params.add(type);
}
if (status != null && !status.isEmpty()) {
sql.append(" AND status = ?");
params.add(status);
}
if (search != null && !search.isEmpty()) {
sql.append(" AND UPPER(title) LIKE UPPER(?)");
params.add("%" + search + "%");
}
sql.append(" ORDER BY created_at DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public Map<String, Object> findByIdInternal(String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
id
);
return results.isEmpty() ? null : results.get(0);
}
public void updateStatus(String id, String status) {
jdbcTemplate.update(
"UPDATE knowledge_items SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
status, id
);
}
public void updateTitle(String id, String title) {
jdbcTemplate.update(
"UPDATE knowledge_items SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
title, id
);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class StudyCardRepository {
@@ -12,5 +15,80 @@ public class StudyCardRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for study_cards, SM-2 queries
public String insert(String userId, String knowledgeItemId, String front, String back) {
if (knowledgeItemId != null) {
jdbcTemplate.update(
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, knowledgeItemId, front, back
);
} else {
jdbcTemplate.update(
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, front, back
);
}
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM study_cards WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> getDueCards(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(sc.id) AS id, RAWTOHEX(sc.knowledge_item_id) AS knowledge_item_id, " +
"sc.front, sc.back, sc.ease_factor, sc.interval_days, sc.repetitions, sc.next_review_at, " +
"ki.title AS knowledge_title " +
"FROM study_cards sc " +
"LEFT JOIN knowledge_items ki ON ki.id = sc.knowledge_item_id " +
"WHERE sc.user_id = HEXTORAW(?) AND sc.next_review_at <= SYSTIMESTAMP " +
"ORDER BY sc.next_review_at ASC",
userId
);
}
public List<Map<String, Object>> getByKnowledgeItem(String userId, String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at " +
"FROM study_cards WHERE user_id = HEXTORAW(?) AND knowledge_item_id = HEXTORAW(?) ORDER BY created_at ASC",
userId, knowledgeItemId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(knowledge_item_id) AS knowledge_item_id, " +
"front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at " +
"FROM study_cards WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateSm2(String id, double easeFactor, int intervalDays, int repetitions, int nextReviewDaysFromNow) {
jdbcTemplate.update(
"UPDATE study_cards SET ease_factor = ?, interval_days = ?, repetitions = ?, " +
"next_review_at = SYSTIMESTAMP + INTERVAL '1' DAY * ?, updated_at = SYSTIMESTAMP " +
"WHERE RAWTOHEX(id) = ?",
easeFactor, intervalDays, repetitions, nextReviewDaysFromNow, id
);
}
public int countDue(String userId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM study_cards WHERE user_id = HEXTORAW(?) AND next_review_at <= SYSTIMESTAMP",
Integer.class, userId
);
return count != null ? count : 0;
}
public int countByKnowledgeItem(String knowledgeItemId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM study_cards WHERE knowledge_item_id = HEXTORAW(?)",
Integer.class, knowledgeItemId
);
return count != null ? count : 0;
}
}

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository
public class TagRepository {
@@ -12,5 +15,83 @@ public class TagRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for tags, knowledge_item_tags
public String insert(String userId, String name, String color) {
jdbcTemplate.update(
"INSERT INTO tags (id, user_id, name, color, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
userId, name, color
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM tags WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color, t.created_at, " +
"(SELECT COUNT(*) FROM knowledge_item_tags kit WHERE kit.tag_id = t.id) AS item_count " +
"FROM tags t WHERE t.user_id = HEXTORAW(?) ORDER BY t.name",
userId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, color, created_at " +
"FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateName(String id, String name) {
jdbcTemplate.update("UPDATE tags SET name = ? WHERE RAWTOHEX(id) = ?", name, id);
}
public void updateColor(String id, String color) {
jdbcTemplate.update("UPDATE tags SET color = ? WHERE RAWTOHEX(id) = ?", color, id);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM knowledge_item_tags WHERE tag_id = (SELECT id FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
id, userId
);
jdbcTemplate.update(
"DELETE FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
// --- Knowledge Item ↔ Tag 매핑 ---
public void tagItem(String knowledgeItemId, String tagId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
Integer.class, knowledgeItemId, tagId
);
if (count != null && count > 0) return;
jdbcTemplate.update(
"INSERT INTO knowledge_item_tags (knowledge_item_id, tag_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
knowledgeItemId, tagId
);
}
public void untagItem(String knowledgeItemId, String tagId) {
jdbcTemplate.update(
"DELETE FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
knowledgeItemId, tagId
);
}
public List<Map<String, Object>> getTagsForItem(String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color " +
"FROM tags t JOIN knowledge_item_tags kit ON kit.tag_id = t.id " +
"WHERE kit.knowledge_item_id = HEXTORAW(?) ORDER BY t.name",
knowledgeItemId
);
}
}

View File

@@ -3,6 +3,10 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Repository
public class TodoRepository {
@@ -12,5 +16,134 @@ public class TodoRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: CRUD for todos table
public String insert(String userId, String title, String description, String priority, String dueDate, String parentId) {
if (parentId != null) {
jdbcTemplate.update(
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 'PENDING', ?, " +
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, parentId, title, description, priority, dueDate, dueDate
);
} else {
jdbcTemplate.update(
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 'PENDING', ?, " +
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, title, description, priority, dueDate, dueDate
);
}
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM todos WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId, String status, String priority, String dueDate) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE user_id = HEXTORAW(?) AND parent_id IS NULL"
);
List<Object> params = new ArrayList<>();
params.add(userId);
if (status != null && !status.isEmpty()) {
sql.append(" AND status = ?");
params.add(status);
}
if (priority != null && !priority.isEmpty()) {
sql.append(" AND priority = ?");
params.add(priority);
}
if (dueDate != null && !dueDate.isEmpty()) {
sql.append(" AND due_date <= TO_DATE(?, 'YYYY-MM-DD')");
params.add(dueDate);
}
sql.append(" ORDER BY CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 END, created_at DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public List<Map<String, Object>> findSubtasks(String userId, String parentId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?) ORDER BY created_at ASC",
parentId, userId
);
}
public void updateStatus(String id, String status) {
jdbcTemplate.update(
"UPDATE todos SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
status, id
);
}
public void updateTitle(String id, String title) {
jdbcTemplate.update(
"UPDATE todos SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
title, id
);
}
public void updatePriority(String id, String priority) {
jdbcTemplate.update(
"UPDATE todos SET priority = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
priority, id
);
}
public void updateDueDate(String id, String dueDate) {
if (dueDate != null && !dueDate.isEmpty()) {
jdbcTemplate.update(
"UPDATE todos SET due_date = TO_DATE(?, 'YYYY-MM-DD'), updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
dueDate, id
);
} else {
jdbcTemplate.update(
"UPDATE todos SET due_date = NULL, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
id
);
}
}
public void delete(String userId, String id) {
// 서브태스크 먼저 삭제
jdbcTemplate.update(
"DELETE FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?)",
id, userId
);
jdbcTemplate.update(
"DELETE FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
public int countByUser(String userId, String status) {
if (status != null) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?) AND status = ?",
Integer.class, userId, status
);
return count != null ? count : 0;
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?)",
Integer.class, userId
);
return count != null ? count : 0;
}
}

View File

@@ -3,6 +3,8 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Map;
@Repository
public class UserRepository {
@@ -12,5 +14,43 @@ public class UserRepository {
this.jdbcTemplate = jdbcTemplate;
}
// TODO: findByGoogleSub, upsert, updateRefreshToken
public Map<String, Object> findByGoogleSub(String googleSub) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub FROM users WHERE google_sub = ?",
googleSub
);
return results.isEmpty() ? null : results.get(0);
}
public Map<String, Object> upsert(String email, String displayName, String avatarUrl, String googleSub) {
var existing = findByGoogleSub(googleSub);
if (existing != null) {
jdbcTemplate.update(
"UPDATE users SET display_name = ?, avatar_url = ?, updated_at = SYSTIMESTAMP WHERE google_sub = ?",
displayName, avatarUrl, googleSub
);
return findByGoogleSub(googleSub);
} else {
jdbcTemplate.update(
"INSERT INTO users (id, email, display_name, avatar_url, google_sub, created_at, updated_at) VALUES (SYS_GUID(), ?, ?, ?, ?, SYSTIMESTAMP, SYSTIMESTAMP)",
email, displayName, avatarUrl, googleSub
);
return findByGoogleSub(googleSub);
}
}
public void updateRefreshToken(String userId, String hashedRefreshToken) {
jdbcTemplate.update(
"UPDATE users SET refresh_token = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
hashedRefreshToken, userId
);
}
public Map<String, Object> findById(String userId) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token FROM users WHERE RAWTOHEX(id) = ?",
userId
);
return results.isEmpty() ? null : results.get(0);
}
}

View File

@@ -1,34 +1,147 @@
package com.sundol.service;
import com.sundol.dto.LoginResponse;
import com.sundol.exception.AppException;
import com.sundol.repository.UserRepository;
import com.sundol.security.JwtProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final JwtProvider jwtProvider;
private final WebClient webClient;
@Value("${google.client-id}")
private String googleClientId;
@Value("${google.client-secret}")
private String googleClientSecret;
@Value("${google.redirect-uri}")
private String googleRedirectUri;
public AuthService(UserRepository userRepository, JwtProvider jwtProvider) {
this.userRepository = userRepository;
this.jwtProvider = jwtProvider;
this.webClient = WebClient.builder().build();
}
public Mono<LoginResponse> googleLogin(String idToken) {
// TODO: Verify Google ID token, upsert user, issue JWT pair
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
public Mono<LoginResponse> googleLogin(String code) {
// Exchange auth code for tokens via Google token endpoint
return webClient.post()
.uri("https://oauth2.googleapis.com/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.bodyValue("code=" + code
+ "&client_id=" + googleClientId
+ "&client_secret=" + googleClientSecret
+ "&redirect_uri=" + googleRedirectUri
+ "&grant_type=authorization_code")
.retrieve()
.bodyToMono(Map.class)
.flatMap(tokenResponse -> {
String accessToken = (String) tokenResponse.get("access_token");
if (accessToken == null) {
return Mono.error(new AppException(HttpStatus.UNAUTHORIZED, "Failed to exchange auth code"));
}
// Get user info from Google
return webClient.get()
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(Map.class);
})
.flatMap(userInfo -> Mono.fromCallable(() -> {
String googleSub = (String) userInfo.get("id");
String email = (String) userInfo.get("email");
String name = (String) userInfo.get("name");
String picture = (String) userInfo.get("picture");
if (googleSub == null || email == null) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid Google user info");
}
// Upsert user
Map<String, Object> user = userRepository.upsert(email, name, picture, googleSub);
String userId = (String) user.get("ID");
// Create JWT pair
String jwt = jwtProvider.createAccessToken(userId, email);
String refreshToken = jwtProvider.createRefreshToken(userId, email);
// Store hashed refresh token
userRepository.updateRefreshToken(userId, sha256(refreshToken));
log.info("User logged in: {} ({})", email, userId);
return new LoginResponse(jwt, refreshToken);
}).subscribeOn(Schedulers.boundedElastic()));
}
public Mono<LoginResponse> refresh(String refreshToken) {
// TODO: Validate refresh token, issue new token pair
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
if (!jwtProvider.validateToken(refreshToken)) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
}
var claims = jwtProvider.parseToken(refreshToken);
String type = claims.get("type", String.class);
if (!"REFRESH".equals(type)) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Not a refresh token");
}
String userId = claims.getSubject();
Map<String, Object> user = userRepository.findById(userId);
if (user == null) {
throw new AppException(HttpStatus.UNAUTHORIZED, "User not found");
}
// Verify stored hash matches
String storedHash = (String) user.get("REFRESH_TOKEN");
if (storedHash == null || !storedHash.equals(sha256(refreshToken))) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
}
String email = (String) user.get("EMAIL");
// Issue new pair (rotation)
String newAccessToken = jwtProvider.createAccessToken(userId, email);
String newRefreshToken = jwtProvider.createRefreshToken(userId, email);
userRepository.updateRefreshToken(userId, sha256(newRefreshToken));
return new LoginResponse(newAccessToken, newRefreshToken);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> logout(String userId) {
// TODO: Invalidate refresh token
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
userRepository.updateRefreshToken(userId, null);
log.info("User logged out: {}", userId);
}).subscribeOn(Schedulers.boundedElastic()).then();
}
private String sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -1,43 +1,186 @@
package com.sundol.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.exception.AppException;
import com.sundol.repository.ChatRepository;
import com.sundol.repository.ChunkEmbeddingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class ChatService {
private final ChatRepository chatRepository;
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private static final int RAG_TOP_K = 5;
public ChatService(ChatRepository chatRepository) {
private final ChatRepository chatRepository;
private final ChunkEmbeddingRepository embeddingRepository;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public ChatService(
ChatRepository chatRepository,
ChunkEmbeddingRepository embeddingRepository,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.chatRepository = chatRepository;
this.embeddingRepository = embeddingRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
}
public Mono<List<Map<String, Object>>> listSessions(String userId) {
// TODO: List chat sessions for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> chatRepository.listSessions(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> createSession(String userId) {
// TODO: Create new chat session
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
String id = chatRepository.createSession(userId, "New Chat");
return chatRepository.findSession(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) {
// TODO: Get messages for chat session
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
if (session == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
}
return chatRepository.getMessages(sessionId);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) {
// TODO: RAG pipeline - embed query, search, build prompt, call OCI GenAI
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
if (session == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
}
// 1. 사용자 메시지 저장
chatRepository.insertMessage(sessionId, "user", content, null, null);
// 2. RAG: 쿼리 임베딩 → 유사 청크 검색
List<Map<String, Object>> relevantChunks = List.of();
String contextBlock = "";
if (genAiService.isConfigured()) {
try {
List<float[]> embeddings = genAiService.embedTexts(List.of(content), "SEARCH_QUERY");
relevantChunks = embeddingRepository.searchSimilar(userId, embeddings.get(0), RAG_TOP_K);
if (!relevantChunks.isEmpty()) {
contextBlock = relevantChunks.stream()
.map(chunk -> {
String title = (String) chunk.get("TITLE");
String chunkContent = (String) chunk.get("CONTENT");
String source = (String) chunk.get("SOURCE_URL");
String header = title != null ? "[" + title + "]" : "[Untitled]";
if (source != null) header += " (" + source + ")";
return header + "\n" + chunkContent;
})
.collect(Collectors.joining("\n\n---\n\n"));
}
} catch (Exception e) {
log.warn("RAG search failed, proceeding without context", e);
}
}
// 3. 대화 히스토리 조회 (최근 20개)
List<Map<String, Object>> history = chatRepository.getMessages(sessionId);
int historyStart = Math.max(0, history.size() - 20);
List<Map<String, Object>> recentHistory = history.subList(historyStart, history.size());
// 4. LLM 프롬프트 구성
String systemMsg = buildSystemPrompt(contextBlock);
String userMsg = buildUserPrompt(recentHistory, content);
// 5. LLM 호출
String assistantResponse;
try {
assistantResponse = genAiService.chat(systemMsg, userMsg, null);
} catch (Exception e) {
log.error("LLM chat failed", e);
assistantResponse = "죄송합니다, 응답을 생성하는 중 오류가 발생했습니다.";
}
// 6. 응답 저장
String sourceChunksJson = null;
if (!relevantChunks.isEmpty()) {
try {
List<Map<String, Object>> sources = relevantChunks.stream()
.map(c -> Map.of(
"knowledgeItemId", (Object) c.get("KNOWLEDGE_ITEM_ID"),
"title", c.get("TITLE") != null ? c.get("TITLE") : "Untitled",
"chunkIndex", c.get("CHUNK_INDEX"),
"distance", c.get("DISTANCE")
))
.toList();
sourceChunksJson = objectMapper.writeValueAsString(sources);
} catch (Exception e) {
log.warn("Failed to serialize source chunks", e);
}
}
chatRepository.insertMessage(sessionId, "assistant", assistantResponse, sourceChunksJson, null);
// 7. 첫 메시지면 세션 제목 업데이트
if (history.size() <= 1) {
String sessionTitle = content.length() > 50
? content.substring(0, 50) + "..." : content;
chatRepository.updateSessionTitle(sessionId, sessionTitle);
}
chatRepository.touchSession(sessionId);
return Map.of(
"role", (Object) "assistant",
"content", assistantResponse,
"sourceChunks", sourceChunksJson != null ? sourceChunksJson : "[]"
);
}).subscribeOn(Schedulers.boundedElastic());
}
private String buildSystemPrompt(String contextBlock) {
StringBuilder sb = new StringBuilder();
sb.append("You are SUNDOL, a helpful personal knowledge assistant. ");
sb.append("Answer questions based on the user's knowledge base. ");
sb.append("If the context contains relevant information, use it to answer accurately. ");
sb.append("If the context doesn't contain relevant information, say so honestly. ");
sb.append("Respond in the same language the user uses.");
if (!contextBlock.isBlank()) {
sb.append("\n\n--- Knowledge Context ---\n\n");
sb.append(contextBlock);
sb.append("\n\n--- End Context ---");
}
return sb.toString();
}
private String buildUserPrompt(List<Map<String, Object>> history, String currentMessage) {
StringBuilder sb = new StringBuilder();
// 최근 대화 히스토리를 포함하여 맥락 유지 (현재 메시지 제외)
for (int i = 0; i < history.size() - 1; i++) {
Map<String, Object> msg = history.get(i);
String role = (String) msg.get("ROLE");
Object contentObj = msg.get("CONTENT");
String content = contentObj != null ? contentObj.toString() : "";
sb.append(role.equals("user") ? "User: " : "Assistant: ");
sb.append(content).append("\n\n");
}
sb.append(currentMessage);
return sb.toString();
}
public Mono<Void> deleteSession(String userId, String sessionId) {
// TODO: Delete chat session and messages
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> chatRepository.deleteSession(userId, sessionId))
.subscribeOn(Schedulers.boundedElastic()).then();
}
}

View File

@@ -0,0 +1,37 @@
package com.sundol.service;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class ChunkingService {
private static final int CHUNK_SIZE_TOKENS = 500;
private static final int CHUNK_OVERLAP_TOKENS = 50;
public List<String> chunk(String text) {
if (text == null || text.isBlank()) {
return List.of();
}
String[] words = text.split("\\s+");
int chunkWords = (int) (CHUNK_SIZE_TOKENS * 0.75);
int overlapWords = (int) (CHUNK_OVERLAP_TOKENS * 0.75);
List<String> chunks = new ArrayList<>();
int i = 0;
while (i < words.length) {
int end = Math.min(i + chunkWords, words.length);
chunks.add(String.join(" ", java.util.Arrays.copyOfRange(words, i, end)));
i += chunkWords - overlapWords;
}
return chunks;
}
public int estimateTokenCount(String text) {
if (text == null || text.isBlank()) return 0;
return (int) (text.split("\\s+").length / 0.75);
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.HabitRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.HabitRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -18,32 +21,85 @@ public class HabitService {
}
public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List habits for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
List<Map<String, Object>> habits = habitRepository.list(userId);
for (Map<String, Object> habit : habits) {
String habitId = (String) habit.get("ID");
habit.put("CHECKED_TODAY", habitRepository.hasCheckedInToday(habitId));
}
return habits;
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
// TODO: Create habit
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
String habitType = request.habitType() != null ? request.habitType() : "DAILY";
String color = request.color() != null ? request.color() : "#6366f1";
String id = habitRepository.insert(
userId, request.name(), request.description(),
habitType, request.targetDays(), color
);
return habitRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update habit
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
if (updates.containsKey("name")) {
habitRepository.updateName(id, (String) updates.get("name"));
}
return habitRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> delete(String userId, String id) {
// TODO: Delete habit and logs
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
habitRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<Map<String, Object>> checkin(String userId, String id, String note) {
// TODO: Check in for today
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
if (habitRepository.hasCheckedInToday(id)) {
throw new AppException(HttpStatus.CONFLICT, "Already checked in today");
}
habitRepository.insertLog(id, note);
// 스트릭 갱신
int currentStreak = habitRepository.calculateCurrentStreak(id);
Number bestStreakNum = (Number) habit.get("STREAK_BEST");
int bestStreak = bestStreakNum != null ? bestStreakNum.intValue() : 0;
if (currentStreak > bestStreak) {
bestStreak = currentStreak;
}
habitRepository.updateStreak(id, currentStreak, bestStreak);
Map<String, Object> updated = habitRepository.findById(userId, id);
updated.put("CHECKED_TODAY", true);
return updated;
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) {
// TODO: Get habit logs
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
return habitRepository.getLogs(id, from, to);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -0,0 +1,278 @@
package com.sundol.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.repository.CategoryRepository;
import com.sundol.repository.ChunkEmbeddingRepository;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class IngestPipelineService {
private static final Logger log = LoggerFactory.getLogger(IngestPipelineService.class);
private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final ChunkEmbeddingRepository embeddingRepository;
private final CategoryRepository categoryRepository;
private final ChunkingService chunkingService;
private final WebCrawlerService webCrawlerService;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public IngestPipelineService(
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
ChunkEmbeddingRepository embeddingRepository,
CategoryRepository categoryRepository,
ChunkingService chunkingService,
WebCrawlerService webCrawlerService,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.embeddingRepository = embeddingRepository;
this.categoryRepository = categoryRepository;
this.chunkingService = chunkingService;
this.webCrawlerService = webCrawlerService;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
}
private static final int TITLE_MAX_LENGTH = 80;
private static final int TEXT_PREVIEW_LENGTH = 3000;
/**
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
*/
private String generateTitle(String text, String modelId) {
if (genAiService.isConfigured()) {
try {
String preview = text.length() > TEXT_PREVIEW_LENGTH
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
String systemMsg = "You are a helpful assistant that generates concise titles. " +
"Respond with ONLY the title, nothing else. " +
"The title should be in the same language as the content. " +
"Keep it under 60 characters.";
String userMsg = "Generate a concise, descriptive title for the following content:\n\n" + preview;
String title = genAiService.chat(systemMsg, userMsg, modelId).strip();
// 따옴표 제거
title = title.replaceAll("^\"|\"$", "").replaceAll("^'|'$", "");
if (!title.isBlank() && title.length() <= TITLE_MAX_LENGTH) {
log.info("LLM generated title: {}", title);
return title;
}
} catch (Exception e) {
log.warn("LLM title generation failed, falling back to text truncation", e);
}
}
// Fallback: 텍스트 첫 줄
String firstLine = text.strip().split("\\r?\\n", 2)[0].strip();
if (firstLine.length() <= TITLE_MAX_LENGTH) {
return firstLine;
}
return firstLine.substring(0, TITLE_MAX_LENGTH - 3) + "...";
}
/**
* LLM으로 2~4 depth 카테고리 추출. 기존 카테고리 목록을 컨텍스트로 전달.
*/
private List<String> extractCategoryPaths(String userId, String text, String modelId) {
if (!genAiService.isConfigured()) {
log.info("OCI GenAI not configured, skipping categorization");
return List.of();
}
try {
// 기존 카테고리 목록
List<Map<String, Object>> existing = categoryRepository.findAllByUser(userId);
String existingPaths = existing.stream()
.map(c -> (String) c.get("FULL_PATH"))
.collect(Collectors.joining("\n"));
String preview = text.length() > TEXT_PREVIEW_LENGTH
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
String systemMsg = "You are a categorization assistant. " +
"Analyze the content and assign 1-3 hierarchical categories. " +
"Each category should be 2-4 levels deep, separated by '/'. " +
"Examples: '건강/운동/웨이트트레이닝', 'IT/AI/LLM', '요리/한식'. " +
"Respond with ONLY a JSON array of category path strings. " +
"No explanation, no markdown formatting. Just the JSON array.";
StringBuilder userMsg = new StringBuilder();
if (!existingPaths.isBlank()) {
userMsg.append("Existing categories (reuse these when appropriate):\n");
userMsg.append(existingPaths);
userMsg.append("\n\n");
}
userMsg.append("Categorize the following content:\n\n");
userMsg.append(preview);
String response = genAiService.chat(systemMsg, userMsg.toString(), modelId).strip();
// JSON 배열 파싱 — 마크다운 코드블록 제거
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
List<String> paths = objectMapper.readValue(response, new TypeReference<>() {});
// 유효성 검증: 슬래시가 있고 빈 값 아닌 것만
return paths.stream()
.filter(p -> p != null && !p.isBlank() && p.contains("/"))
.map(String::strip)
.toList();
} catch (Exception e) {
log.warn("LLM categorization failed", e);
return List.of();
}
}
/**
* 청크들을 임베딩하여 knowledge_chunk_embeddings 테이블에 저장.
*/
private void embedChunks(String knowledgeItemId, List<String> chunkContents) {
if (!genAiService.isConfigured()) {
log.info("OCI GenAI not configured, skipping embedding");
return;
}
try {
// chunk ID 목록 조회
List<Map<String, Object>> storedChunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
if (storedChunks.size() != chunkContents.size()) {
log.warn("Chunk count mismatch: stored={}, content={}", storedChunks.size(), chunkContents.size());
}
List<float[]> embeddings = genAiService.embedTexts(chunkContents, "SEARCH_DOCUMENT");
String embedModelId = "cohere.embed-v4.0";
for (int i = 0; i < Math.min(storedChunks.size(), embeddings.size()); i++) {
String chunkId = (String) storedChunks.get(i).get("ID");
embeddingRepository.upsertEmbedding(chunkId, embedModelId, embeddings.get(i));
}
log.info("Item {} embedded {} chunks", knowledgeItemId, embeddings.size());
} catch (Exception e) {
log.warn("Embedding failed for item {}, continuing pipeline", knowledgeItemId, e);
}
}
private void categorize(String knowledgeItemId, String userId, String text, String modelId) {
try {
List<String> categoryPaths = extractCategoryPaths(userId, text, modelId);
for (String path : categoryPaths) {
String categoryId = categoryRepository.findOrCreate(userId, path);
categoryRepository.linkCategory(knowledgeItemId, categoryId);
}
log.info("Item {} categorized with {} categories: {}", knowledgeItemId, categoryPaths.size(), categoryPaths);
} catch (Exception e) {
log.warn("Categorization failed for item {}, continuing pipeline", knowledgeItemId, e);
}
}
@Async
public void runPipeline(String knowledgeItemId, String modelId) {
try {
Map<String, Object> item = knowledgeRepository.findByIdInternal(knowledgeItemId);
if (item == null) {
log.error("Knowledge item not found: {}", knowledgeItemId);
return;
}
String type = (String) item.get("TYPE");
String sourceUrl = (String) item.get("SOURCE_URL");
Object rawTextObj = item.get("RAW_TEXT");
String rawText = rawTextObj != null ? rawTextObj.toString() : null;
// Step 1: Extract text
knowledgeRepository.updateStatus(knowledgeItemId, "EXTRACTING");
String extractedText;
switch (type) {
case "WEB" -> {
extractedText = webCrawlerService.crawl(sourceUrl);
// WEB은 페이지 제목을 우선 시도
String title = (String) item.get("TITLE");
if (title == null || title.isBlank()) {
try {
String pageTitle = webCrawlerService.extractTitle(sourceUrl);
if (pageTitle != null && !pageTitle.isBlank()) {
knowledgeRepository.updateTitle(knowledgeItemId, pageTitle);
item.put("TITLE", pageTitle);
}
} catch (Exception e) {
log.warn("Failed to extract title from {}", sourceUrl, e);
}
}
}
case "YOUTUBE" -> {
if (rawText == null || rawText.isBlank()) {
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
log.error("YouTube item has no raw text: {}", knowledgeItemId);
return;
}
extractedText = rawText;
}
case "TEXT" -> {
if (rawText == null || rawText.isBlank()) {
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
log.error("Text item has no raw text: {}", knowledgeItemId);
return;
}
extractedText = rawText;
}
default -> {
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
log.error("Unknown type: {}", type);
return;
}
}
// Auto-generate title if not set
String currentTitle = (String) item.get("TITLE");
if (currentTitle == null || currentTitle.isBlank()) {
String autoTitle = generateTitle(extractedText, modelId);
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
}
// Step 2: Chunk
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
List<String> chunks = chunkingService.chunk(extractedText);
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
for (int i = 0; i < chunks.size(); i++) {
String chunkContent = chunks.get(i);
int tokenCount = chunkingService.estimateTokenCount(chunkContent);
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
}
// Step 3: Categorize
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
// Step 4: Embedding
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
embedChunks(knowledgeItemId, chunks);
// Done
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
log.info("Pipeline complete for item {}: {} chunks stored", knowledgeItemId, chunks.size());
} catch (Exception e) {
log.error("Pipeline failed for item {}", knowledgeItemId, e);
try {
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
} catch (Exception ex) {
log.error("Failed to update status to FAILED", ex);
}
}
}
}

View File

@@ -1,9 +1,14 @@
package com.sundol.service;
import com.sundol.dto.IngestRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.CategoryRepository;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -12,38 +17,77 @@ import java.util.Map;
public class KnowledgeService {
private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final CategoryRepository categoryRepository;
private final IngestPipelineService pipelineService;
public KnowledgeService(KnowledgeRepository knowledgeRepository) {
public KnowledgeService(
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
CategoryRepository categoryRepository,
IngestPipelineService pipelineService) {
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.categoryRepository = categoryRepository;
this.pipelineService = pipelineService;
}
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
// TODO: Query knowledge items with filters
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
// TODO: Create knowledge item, trigger async ingest pipeline
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
String id = knowledgeRepository.insert(
userId, request.type(), request.title(), request.url(), request.rawText()
);
// Trigger async pipeline
pipelineService.runPipeline(id, request.modelId());
return Map.of(
"id", (Object) id,
"status", "PENDING"
);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> getById(String userId, String id) {
// TODO: Get knowledge item by ID
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
item.put("CATEGORIES", categoryRepository.findByKnowledgeItemId(id));
return item;
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update knowledge item fields
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
if (updates.containsKey("title")) {
knowledgeRepository.updateTitle(id, (String) updates.get("title"));
}
return knowledgeRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> delete(String userId, String id) {
// TODO: Delete knowledge item and all chunks
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
.subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
// TODO: List chunks for knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
return chunkRepository.findByKnowledgeItemId(id);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -0,0 +1,202 @@
package com.sundol.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Service
public class OciGenAiService {
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
private final String apiKey;
private final String compartment;
private final String defaultModel;
private final String baseUrl;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
public static final List<Map<String, String>> AVAILABLE_MODELS = List.of(
Map.of("id", "google.gemini-2.5-pro", "name", "Gemini 2.5 Pro", "vendor", "Google"),
Map.of("id", "google.gemini-2.5-flash", "name", "Gemini 2.5 Flash", "vendor", "Google"),
Map.of("id", "google.gemini-2.5-flash-lite", "name", "Gemini 2.5 Flash Lite", "vendor", "Google"),
Map.of("id", "xai.grok-4.20-reasoning", "name", "Grok 4.20 Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4.20-non-reasoning", "name", "Grok 4.20 Non-Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4-1-fast-reasoning", "name", "Grok 4-1 Fast Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4-1-fast-non-reasoning", "name", "Grok 4-1 Fast Non-Reasoning", "vendor", "xAI"),
Map.of("id", "xai.grok-4", "name", "Grok 4", "vendor", "xAI"),
Map.of("id", "xai.grok-3", "name", "Grok 3", "vendor", "xAI"),
Map.of("id", "xai.grok-3-mini", "name", "Grok 3 Mini", "vendor", "xAI"),
Map.of("id", "openai.gpt-oss-120b", "name", "GPT-OSS 120B", "vendor", "OpenAI"),
Map.of("id", "openai.gpt-oss-20b", "name", "GPT-OSS 20B", "vendor", "OpenAI"),
Map.of("id", "cohere.command-a-03-2025", "name", "Command A", "vendor", "Cohere"),
Map.of("id", "cohere.command-a-reasoning", "name", "Command A Reasoning", "vendor", "Cohere"),
Map.of("id", "cohere.command-r-plus-08-2024", "name", "Command R+", "vendor", "Cohere"),
Map.of("id", "meta.llama-4-maverick-17b-128e-instruct-fp8", "name", "Llama 4 Maverick 17B", "vendor", "Meta"),
Map.of("id", "meta.llama-4-scout-17b-16e-instruct", "name", "Llama 4 Scout 17B", "vendor", "Meta")
);
public OciGenAiService(
@Value("${oci.genai.api-key:}") String apiKey,
@Value("${oci.genai.compartment:}") String compartment,
@Value("${oci.genai.model:google.gemini-2.5-flash}") String defaultModel,
@Value("${oci.genai.base-url:}") String baseUrl,
ObjectMapper objectMapper) {
this.apiKey = apiKey;
this.compartment = compartment;
this.defaultModel = defaultModel;
this.baseUrl = baseUrl;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
}
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank()
&& compartment != null && !compartment.isBlank();
}
public String getDefaultModel() {
return defaultModel;
}
/**
* OCI GenAI Chat API 호출.
*/
public String chat(String systemMessage, String userMessage, String modelId) throws Exception {
if (!isConfigured()) {
throw new IllegalStateException("OCI GenAI is not configured");
}
if (modelId == null || modelId.isBlank()) {
modelId = defaultModel;
}
Map<String, Object> payload = Map.of(
"compartmentId", compartment,
"servingMode", Map.of(
"servingType", "ON_DEMAND",
"modelId", modelId
),
"chatRequest", Map.of(
"apiFormat", "GENERIC",
"messages", List.of(
Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))),
Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage)))
),
"maxTokens", 4096,
"temperature", 0.3
)
);
String body = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/chat"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OCI GenAI error {} (model={}): {}", response.statusCode(), modelId,
response.body().substring(0, Math.min(response.body().length(), 500)));
throw new RuntimeException("OCI GenAI returned " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode choices = root.path("chatResponse").path("choices");
if (choices.isArray() && !choices.isEmpty()) {
JsonNode content = choices.get(0).path("message").path("content");
if (content.isArray() && !content.isEmpty()) {
return content.get(0).path("text").asText("");
}
}
throw new RuntimeException("Unexpected OCI GenAI response structure");
}
private static final String EMBED_MODEL = "cohere.embed-v4.0";
private static final int EMBED_BATCH_SIZE = 96;
/**
* OCI GenAI Embed API 호출. 최대 96개씩 배치 처리.
* @param texts 임베딩할 텍스트 목록
* @param inputType SEARCH_DOCUMENT (저장 시) 또는 SEARCH_QUERY (검색 시)
* @return 각 텍스트에 대응하는 float[] 벡터 리스트
*/
public List<float[]> embedTexts(List<String> texts, String inputType) throws Exception {
if (!isConfigured()) {
throw new IllegalStateException("OCI GenAI is not configured");
}
List<float[]> allEmbeddings = new ArrayList<>();
for (int start = 0; start < texts.size(); start += EMBED_BATCH_SIZE) {
int end = Math.min(start + EMBED_BATCH_SIZE, texts.size());
List<String> batch = texts.subList(start, end);
Map<String, Object> payload = Map.of(
"compartmentId", compartment,
"servingMode", Map.of(
"servingType", "ON_DEMAND",
"modelId", EMBED_MODEL
),
"inputs", batch,
"inputType", inputType,
"truncate", "END"
);
String body = objectMapper.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/embedText"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(120))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("OCI GenAI embed error {}: {}", response.statusCode(),
response.body().substring(0, Math.min(response.body().length(), 500)));
throw new RuntimeException("OCI GenAI embed returned " + response.statusCode());
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode embeddings = root.path("embeddings");
if (!embeddings.isArray()) {
throw new RuntimeException("Unexpected embed response: no embeddings array");
}
for (JsonNode embNode : embeddings) {
float[] vec = new float[embNode.size()];
for (int i = 0; i < embNode.size(); i++) {
vec[i] = (float) embNode.get(i).asDouble();
}
allEmbeddings.add(vec);
}
log.debug("Embedded batch [{}-{}] of {} texts", start, end, texts.size());
}
return allEmbeddings;
}
}

View File

@@ -1,8 +1,11 @@
package com.sundol.service;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.ChunkEmbeddingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -10,14 +13,24 @@ import java.util.Map;
@Service
public class SearchService {
private final KnowledgeChunkRepository chunkRepository;
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
public SearchService(KnowledgeChunkRepository chunkRepository) {
this.chunkRepository = chunkRepository;
private final ChunkEmbeddingRepository embeddingRepository;
private final OciGenAiService genAiService;
public SearchService(ChunkEmbeddingRepository embeddingRepository, OciGenAiService genAiService) {
this.embeddingRepository = embeddingRepository;
this.genAiService = genAiService;
}
/**
* 시맨틱 검색: 쿼리를 임베딩 → VECTOR_DISTANCE로 유사 청크 검색
*/
public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) {
// TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
List<float[]> embeddings = genAiService.embedTexts(List.of(query), "SEARCH_QUERY");
float[] queryVector = embeddings.get(0);
return embeddingRepository.searchSimilar(userId, queryVector, topK);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -1,8 +1,17 @@
package com.sundol.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.exception.AppException;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository;
import com.sundol.repository.StudyCardRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -10,29 +19,137 @@ import java.util.Map;
@Service
public class StudyCardService {
private final StudyCardRepository studyCardRepository;
private static final Logger log = LoggerFactory.getLogger(StudyCardService.class);
public StudyCardService(StudyCardRepository studyCardRepository) {
private final StudyCardRepository studyCardRepository;
private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public StudyCardService(
StudyCardRepository studyCardRepository,
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.studyCardRepository = studyCardRepository;
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
}
public Mono<List<Map<String, Object>>> getDueCards(String userId) {
// TODO: Get cards due for review today
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> studyCardRepository.getDueCards(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
// TODO: Get cards for a specific knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> studyCardRepository.getByKnowledgeItem(userId, knowledgeItemId))
.subscribeOn(Schedulers.boundedElastic());
}
/**
* Knowledge item의 청크들을 기반으로 LLM이 Q&A 카드를 자동 생성.
*/
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
// TODO: Trigger AI card generation from knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, knowledgeItemId);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
if (!"READY".equals(item.get("STATUS"))) {
throw new AppException(HttpStatus.BAD_REQUEST, "Knowledge item is not ready yet");
}
List<Map<String, Object>> chunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
if (chunks.isEmpty()) {
throw new AppException(HttpStatus.BAD_REQUEST, "No chunks available");
}
// 청크 내용을 합쳐서 LLM에 전달 (최대 4000자)
StringBuilder content = new StringBuilder();
for (Map<String, Object> chunk : chunks) {
Object c = chunk.get("CONTENT");
if (c != null) {
content.append(c.toString()).append("\n\n");
}
if (content.length() > 4000) break;
}
String systemMsg =
"You are a study card generator. Create flashcards from the given content. " +
"Each card has a 'front' (question) and 'back' (answer). " +
"Generate 3-5 cards that test key concepts. " +
"Use the same language as the content. " +
"Respond with ONLY a JSON array: [{\"front\":\"question\",\"back\":\"answer\"},...]. " +
"No markdown, no explanation.";
String userMsg = "Generate study cards from this content:\n\n" + content;
String response = genAiService.chat(systemMsg, userMsg, null);
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
List<Map<String, String>> cards = objectMapper.readValue(response, new TypeReference<>() {});
int created = 0;
for (Map<String, String> card : cards) {
String front = card.get("front");
String back = card.get("back");
if (front != null && back != null && !front.isBlank() && !back.isBlank()) {
studyCardRepository.insert(userId, knowledgeItemId, front, back);
created++;
}
}
log.info("Generated {} study cards for knowledge item {}", created, knowledgeItemId);
return Map.of("generated", (Object) created, "knowledgeItemId", knowledgeItemId);
}).subscribeOn(Schedulers.boundedElastic());
}
/**
* SM-2 알고리즘으로 카드 복습 결과 처리.
* rating: 0(완전 모름) ~ 5(완벽)
*/
public Mono<Map<String, Object>> review(String userId, String id, int rating) {
// TODO: Apply SM-2 algorithm and update card
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> card = studyCardRepository.findById(userId, id);
if (card == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Study card not found");
}
Number efNum = (Number) card.get("EASE_FACTOR");
Number ivNum = (Number) card.get("INTERVAL_DAYS");
Number repNum = (Number) card.get("REPETITIONS");
double easeFactor = efNum != null ? efNum.doubleValue() : 2.5;
int intervalDays = ivNum != null ? ivNum.intValue() : 0;
int repetitions = repNum != null ? repNum.intValue() : 0;
// SM-2 알고리즘
if (rating < 3) {
// 실패: 처음부터 다시
repetitions = 0;
intervalDays = 1;
} else {
// 성공
repetitions++;
if (repetitions == 1) {
intervalDays = 1;
} else if (repetitions == 2) {
intervalDays = 6;
} else {
intervalDays = (int) Math.round(intervalDays * easeFactor);
}
}
// Ease Factor 갱신
easeFactor = easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02));
if (easeFactor < 1.3) easeFactor = 1.3;
studyCardRepository.updateSm2(id, easeFactor, intervalDays, repetitions, intervalDays);
return studyCardRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.TagRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TagRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -18,22 +21,41 @@ public class TagService {
}
public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List tags for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> tagRepository.list(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
// TODO: Create tag
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
String color = request.color() != null ? request.color() : "#6366f1";
String id = tagRepository.insert(userId, request.name(), color);
return tagRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
// TODO: Update tag
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> tag = tagRepository.findById(userId, id);
if (tag == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
}
if (request.name() != null) {
tagRepository.updateName(id, request.name());
}
if (request.color() != null) {
tagRepository.updateColor(id, request.color());
}
return tagRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> delete(String userId, String id) {
// TODO: Delete tag and remove from items
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
Map<String, Object> tag = tagRepository.findById(userId, id);
if (tag == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
}
tagRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.TodoRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TodoRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -18,27 +21,72 @@ public class TodoService {
}
public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) {
// TODO: List todos with filters
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
List<Map<String, Object>> todos = todoRepository.list(userId, status, priority, dueDate);
// 각 todo에 서브태스크 개수 포함
for (Map<String, Object> todo : todos) {
String todoId = (String) todo.get("ID");
List<Map<String, Object>> subtasks = todoRepository.findSubtasks(userId, todoId);
todo.put("SUBTASK_COUNT", subtasks.size());
long doneCount = subtasks.stream()
.filter(s -> "DONE".equals(s.get("STATUS")))
.count();
todo.put("SUBTASK_DONE_COUNT", doneCount);
}
return todos;
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
// TODO: Create todo
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
String priority = request.priority() != null ? request.priority() : "MEDIUM";
String id = todoRepository.insert(
userId, request.title(), request.description(),
priority, request.dueDate(), request.parentId()
);
return todoRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update todo fields
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
if (updates.containsKey("status")) {
todoRepository.updateStatus(id, (String) updates.get("status"));
}
if (updates.containsKey("title")) {
todoRepository.updateTitle(id, (String) updates.get("title"));
}
if (updates.containsKey("priority")) {
todoRepository.updatePriority(id, (String) updates.get("priority"));
}
if (updates.containsKey("dueDate")) {
todoRepository.updateDueDate(id, (String) updates.get("dueDate"));
}
return todoRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
public Mono<Void> delete(String userId, String id) {
// TODO: Delete todo and subtasks
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
todoRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
// TODO: List subtasks for todo
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
return todoRepository.findSubtasks(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -0,0 +1,46 @@
package com.sundol.service;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
@Service
public class WebCrawlerService {
private static final Logger log = LoggerFactory.getLogger(WebCrawlerService.class);
public String crawl(String url) throws IOException {
log.info("Crawling URL: {}", url);
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (compatible; SUNDOL-bot/1.0)")
.timeout(15_000)
.followRedirects(true)
.get();
// Remove non-content elements
doc.select("nav, footer, header, script, style, .ad, #cookie-banner, .sidebar, .comments").remove();
// Prefer article body
Element article = doc.selectFirst("article, main, .post-content, .article-body, .entry-content");
String text = (article != null ? article : doc.body()).text();
// Extract title if available
String title = doc.title();
log.info("Crawled '{}' - {} chars", title, text.length());
return text;
}
public String extractTitle(String url) throws IOException {
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (compatible; SUNDOL-bot/1.0)")
.timeout(10_000)
.get();
return doc.title();
}
}

View File

@@ -12,12 +12,22 @@ jwt:
access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000}
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: ${GOOGLE_REDIRECT_URI:https://sundol.cloud-handson.com/login/callback}
cors:
origin: ${CORS_ORIGIN:http://localhost:3000}
oci:
compartment-id: ${OCI_COMPARTMENT_ID:}
region: ${OCI_REGION:ap-seoul-1}
genai:
api-key: ${OCI_GENAI_API_KEY:}
compartment: ${OCI_GENAI_COMPARTMENT:}
model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash}
base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions}
logging:
level:

6
sundol-frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7411
sundol-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,297 @@
"use client";
import { useEffect, useState, useRef } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Session {
ID: string;
TITLE: string;
CREATED_AT: string;
UPDATED_AT: string;
}
interface Message {
ID: string;
ROLE: string;
CONTENT: string;
SOURCE_CHUNKS: string | null;
CREATED_AT: string;
}
interface SourceChunk {
knowledgeItemId: string;
title: string;
chunkIndex: number;
distance: number;
}
export default function ChatPage() {
const { request } = useApi();
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loadingSessions, setLoadingSessions] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load sessions
useEffect(() => {
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
.then(setSessions)
.catch((err) => console.error("Failed to load sessions:", err))
.finally(() => setLoadingSessions(false));
}, []);
// Load messages when session changes
useEffect(() => {
if (!activeSessionId) {
setMessages([]);
return;
}
request<Message[]>({ method: "GET", url: `/api/chat/sessions/${activeSessionId}/messages` })
.then(setMessages)
.catch((err) => console.error("Failed to load messages:", err));
}, [activeSessionId]);
const handleNewSession = async () => {
try {
const session = await request<Session>({ method: "POST", url: "/api/chat/sessions" });
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.ID);
} catch (err) {
console.error("Failed to create session:", err);
}
};
const handleDeleteSession = async (sessionId: string) => {
try {
await request({ method: "DELETE", url: `/api/chat/sessions/${sessionId}` });
setSessions((prev) => prev.filter((s) => s.ID !== sessionId));
if (activeSessionId === sessionId) {
setActiveSessionId(null);
setMessages([]);
}
} catch (err) {
console.error("Failed to delete session:", err);
}
};
const handleSend = async () => {
if (!input.trim() || sending || !activeSessionId) return;
const userMessage = input.trim();
setInput("");
setSending(true);
// Optimistic UI: add user message immediately
const tempUserMsg: Message = {
ID: "temp-user",
ROLE: "user",
CONTENT: userMessage,
SOURCE_CHUNKS: null,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUserMsg]);
try {
const response = await request<{ role: string; content: string; sourceChunks: string }>({
method: "POST",
url: `/api/chat/sessions/${activeSessionId}/messages`,
data: { content: userMessage },
});
const assistantMsg: Message = {
ID: "temp-assistant-" + Date.now(),
ROLE: "assistant",
CONTENT: response.content,
SOURCE_CHUNKS: response.sourceChunks,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMsg]);
// Refresh sessions for updated title
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
.then(setSessions)
.catch(() => {});
} catch (err) {
console.error("Failed to send message:", err);
const errorMsg: Message = {
ID: "temp-error",
ROLE: "assistant",
CONTENT: "메시지 전송에 실패했습니다.",
SOURCE_CHUNKS: null,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMsg]);
} finally {
setSending(false);
}
};
const parseSourceChunks = (json: string | null): SourceChunk[] => {
if (!json) return [];
try {
return JSON.parse(json);
} catch {
return [];
}
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">AI Chat</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] min-h-[60vh] flex items-center justify-center">
<p className="text-[var(--color-text-muted)]">Start a new conversation to ask questions about your knowledge base.</p>
<main className="max-w-7xl mx-auto px-4 py-4 h-[calc(100vh-64px)] flex gap-4">
{/* Sidebar: Sessions */}
<div className="w-64 flex-shrink-0 flex flex-col">
<button
onClick={handleNewSession}
className="w-full px-4 py-2 mb-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm transition-colors"
>
+ New Chat
</button>
<div className="flex-1 overflow-y-auto space-y-1">
{loadingSessions ? (
<p className="text-sm text-[var(--color-text-muted)] px-2">Loading...</p>
) : sessions.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)] px-2">No conversations yet</p>
) : (
sessions.map((s) => (
<div
key={s.ID}
className={`group flex items-center rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors ${
activeSessionId === s.ID
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={() => setActiveSessionId(s.ID)}
>
<span className="flex-1 truncate">{s.TITLE || "New Chat"}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(s.ID);
}}
className="hidden group-hover:block text-red-400 hover:text-red-300 ml-2 text-xs"
>
</button>
</div>
))
)}
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
{!activeSessionId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-bold mb-2">AI Chat</h2>
<p className="text-[var(--color-text-muted)] mb-4">
Knowledge base를 .
</p>
<button
onClick={handleNewSession}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
>
Start a new chat
</button>
</div>
</div>
) : (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 py-4">
{messages.length === 0 && (
<p className="text-center text-[var(--color-text-muted)] mt-20">
Knowledge base에 .
</p>
)}
{messages.map((msg) => (
<div
key={msg.ID}
className={`flex ${msg.ROLE === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[75%] rounded-xl px-4 py-3 ${
msg.ROLE === "user"
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] border border-[var(--color-border)]"
}`}
>
<p className="whitespace-pre-wrap text-sm">{msg.CONTENT}</p>
{/* Source chunks */}
{msg.ROLE === "assistant" && (() => {
const sources = parseSourceChunks(msg.SOURCE_CHUNKS);
if (sources.length === 0) return null;
return (
<div className="mt-2 pt-2 border-t border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">:</p>
<div className="flex flex-wrap gap-1">
{sources.map((src, i) => (
<a
key={i}
href={`/knowledge/${src.knowledgeItemId}`}
className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
>
{src.title}
</a>
))}
</div>
</div>
);
})()}
</div>
</div>
))}
{sending && (
<div className="flex justify-start">
<div className="bg-[var(--color-bg-card)] border border-[var(--color-border)] rounded-xl px-4 py-3">
<div className="flex gap-1">
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="py-3">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="메시지를 입력하세요..."
disabled={sending}
className="flex-1 px-4 py-3 rounded-xl bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className="px-6 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-xl transition-colors"
>
</button>
</div>
</div>
</>
)}
</div>
</main>
</AuthGuard>

View File

@@ -1,33 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface DashData {
knowledgeCount: number;
dueCards: number;
activeTodos: number;
habitStreaks: number;
chatSessions: number;
tags: number;
}
export default function DashboardPage() {
const { request } = useApi();
const [data, setData] = useState<DashData | null>(null);
useEffect(() => {
Promise.all([
request<unknown[]>({ method: "GET", url: "/api/knowledge" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/study-cards/due" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/todos?status=PENDING" }).catch(() => []),
request<{ STREAK_CURRENT?: number }[]>({ method: "GET", url: "/api/habits" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/chat/sessions" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/tags" }).catch(() => []),
]).then(([knowledge, dueCards, todos, habits, sessions, tags]) => {
const activeStreaks = (habits as { STREAK_CURRENT?: number }[]).filter(
(h) => h.STREAK_CURRENT && h.STREAK_CURRENT > 0
).length;
setData({
knowledgeCount: knowledge.length,
dueCards: dueCards.length,
activeTodos: todos.length,
habitStreaks: activeStreaks,
chatSessions: sessions.length,
tags: tags.length,
});
});
}, []);
const cards: { title: string; value: string; description: string; href: string; color: string }[] = data
? [
{ title: "Knowledge Items", value: String(data.knowledgeCount), description: "수집된 항목", href: "/knowledge", color: "text-blue-400" },
{ title: "Due Study Cards", value: String(data.dueCards), description: "복습 대기 카드", href: "/study", color: "text-purple-400" },
{ title: "Active Todos", value: String(data.activeTodos), description: "진행중인 할 일", href: "/todos", color: "text-yellow-400" },
{ title: "Habit Streaks", value: String(data.habitStreaks), description: "활성 연속 기록", href: "/habits", color: "text-green-400" },
{ title: "Chat Sessions", value: String(data.chatSessions), description: "대화 세션", href: "/chat", color: "text-cyan-400" },
{ title: "Tags", value: String(data.tags), description: "태그 수", href: "#", color: "text-indigo-400" },
]
: [];
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{!data ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<DashCard title="Knowledge Items" value="-" description="Total ingested items" />
<DashCard title="Due Study Cards" value="-" description="Cards due for review" />
<DashCard title="Active Todos" value="-" description="Pending tasks" />
<DashCard title="Habit Streaks" value="-" description="Current active streaks" />
<DashCard title="Chat Sessions" value="-" description="Active conversations" />
<DashCard title="Tags" value="-" description="Knowledge categories" />
{cards.map((card) => (
<Link
key={card.title}
href={card.href}
className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{card.title}</h3>
<p className={`text-3xl font-bold mb-1 ${card.color}`}>{card.value}</p>
<p className="text-sm text-[var(--color-text-muted)]">{card.description}</p>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);
}
function DashCard({ title, value, description }: { title: string; value: string; description: string }) {
return (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{title}</h3>
<p className="text-3xl font-bold mb-1">{value}</p>
<p className="text-sm text-[var(--color-text-muted)]">{description}</p>
</div>
);
}

View File

@@ -1,22 +1,203 @@
"use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Habit {
ID: string;
NAME: string;
DESCRIPTION: string | null;
HABIT_TYPE: string;
COLOR: string;
STREAK_CURRENT: number;
STREAK_BEST: number;
CHECKED_TODAY: boolean;
}
const COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ef4444"];
export default function HabitsPage() {
const { request } = useApi();
const [habits, setHabits] = useState<Habit[]>([]);
const [loading, setLoading] = useState(true);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(COLORS[0]);
const fetchHabits = async () => {
try {
const data = await request<Habit[]>({ method: "GET", url: "/api/habits" });
setHabits(data);
} catch (err) {
console.error("Failed to load habits:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchHabits();
}, []);
const handleAdd = async () => {
if (!newName.trim()) return;
try {
await request({
method: "POST",
url: "/api/habits",
data: { name: newName.trim(), habitType: "DAILY", color: newColor },
});
setNewName("");
setShowAdd(false);
fetchHabits();
} catch (err) {
console.error("Failed to create habit:", err);
}
};
const handleCheckin = async (habitId: string) => {
try {
await request({ method: "POST", url: `/api/habits/${habitId}/checkin`, data: {} });
fetchHabits();
} catch (err) {
console.error("Failed to check in:", err);
}
};
const handleDelete = async (habitId: string) => {
if (!confirm("이 습관을 삭제하시겠습니까?")) return;
try {
await request({ method: "DELETE", url: `/api/habits/${habitId}` });
fetchHabits();
} catch (err) {
console.error("Failed to delete habit:", err);
}
};
// 오늘 요일 (월~일 한글)
const today = new Date();
const weekDays = ["일", "월", "화", "수", "목", "금", "토"];
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold">Habits</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
<p className="text-sm text-[var(--color-text-muted)]">
{today.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "long" })}
</p>
</div>
<button
onClick={() => setShowAdd(!showAdd)}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+ Add Habit
</button>
</div>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No habits tracked yet. Start building good habits.</p>
{/* Add form */}
{showAdd && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="습관 이름 (예: 물 2L 마시기)"
autoFocus
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--color-text-muted)]">:</span>
<div className="flex gap-2">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setNewColor(c)}
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? "scale-125 ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="ml-auto px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
>
</button>
</div>
</div>
)}
{/* Habit list */}
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : habits.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]"> . .</p>
</div>
) : (
<div className="space-y-3">
{habits.map((habit) => (
<div
key={habit.ID}
className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center gap-4">
{/* Check-in button */}
<button
onClick={() => !habit.CHECKED_TODAY && handleCheckin(habit.ID)}
disabled={habit.CHECKED_TODAY}
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
habit.CHECKED_TODAY
? "opacity-90"
: "opacity-50 hover:opacity-100 hover:scale-105"
}`}
style={{ backgroundColor: habit.COLOR || "#6366f1" }}
>
{habit.CHECKED_TODAY ? (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<span className="text-white text-lg font-bold">+</span>
)}
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-medium">{habit.NAME}</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm" style={{ color: habit.COLOR || "#6366f1" }}>
{habit.STREAK_CURRENT > 0 ? `${habit.STREAK_CURRENT}일 연속` : "시작 전"}
</span>
{habit.STREAK_BEST > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
: {habit.STREAK_BEST}
</span>
)}
</div>
</div>
{/* Delete */}
<button
onClick={() => handleDelete(habit.ID)}
className="text-xs text-red-400 hover:text-red-300 px-2"
>
</button>
</div>
</div>
))}
</div>
)}
</main>
</AuthGuard>
);

View File

@@ -0,0 +1,367 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Category {
ID: string;
NAME: string;
DEPTH: number;
FULL_PATH: string;
}
interface KnowledgeItem {
ID: string;
TYPE: string;
TITLE: string;
SOURCE_URL: string;
RAW_TEXT: string;
STATUS: string;
CREATED_AT: string;
UPDATED_AT: string;
CATEGORIES: Category[];
}
interface Chunk {
ID: string;
CHUNK_INDEX: number;
CONTENT: string;
TOKEN_COUNT: number;
}
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-500/20 text-yellow-400",
EXTRACTING: "bg-blue-500/20 text-blue-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
READY: "bg-green-500/20 text-green-400",
FAILED: "bg-red-500/20 text-red-400",
};
const typeLabels: Record<string, string> = {
YOUTUBE: "YouTube",
WEB: "Web",
TEXT: "Text",
};
function extractYouTubeVideoId(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") return u.pathname.slice(1);
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
} catch {
// invalid URL
}
return null;
}
export default function KnowledgeDetailPage() {
const { request } = useApi();
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [item, setItem] = useState<KnowledgeItem | null>(null);
const [chunks, setChunks] = useState<Chunk[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [showChunks, setShowChunks] = useState(false);
const [deleting, setDeleting] = useState(false);
const [generating, setGenerating] = useState(false);
const fetchItem = async () => {
try {
const data = await request<KnowledgeItem>({ method: "GET", url: `/api/knowledge/${id}` });
setItem(data);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to load";
setError(msg);
} finally {
setLoading(false);
}
};
const fetchChunks = async () => {
try {
const data = await request<Chunk[]>({ method: "GET", url: `/api/knowledge/${id}/chunks` });
setChunks(data);
} catch (err) {
console.error("Failed to load chunks:", err);
}
};
useEffect(() => {
fetchItem();
}, [id]);
// Poll while processing
useEffect(() => {
if (!item) return;
const processing = ["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
if (!processing) return;
const interval = setInterval(fetchItem, 3000);
return () => clearInterval(interval);
}, [item?.STATUS]);
const handleSaveTitle = async () => {
if (!titleDraft.trim()) return;
try {
const updated = await request<KnowledgeItem>({
method: "PATCH",
url: `/api/knowledge/${id}`,
data: { title: titleDraft.trim() },
});
setItem(updated);
setEditingTitle(false);
} catch (err) {
console.error("Failed to update title:", err);
}
};
const handleDelete = async () => {
if (!confirm("정말 삭제하시겠습니까?")) return;
setDeleting(true);
try {
await request({ method: "DELETE", url: `/api/knowledge/${id}` });
router.push("/knowledge");
} catch (err) {
console.error("Failed to delete:", err);
setDeleting(false);
}
};
const handleToggleChunks = () => {
if (!showChunks && chunks.length === 0) {
fetchChunks();
}
setShowChunks(!showChunks);
};
if (loading) {
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</main>
</AuthGuard>
);
}
if (error || !item) {
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
<p className="text-red-400">{error || "Item not found"}</p>
<button
onClick={() => router.push("/knowledge")}
className="mt-4 text-sm text-[var(--color-primary)] hover:underline"
>
Back to Knowledge
</button>
</main>
</AuthGuard>
);
}
const videoId = item.TYPE === "YOUTUBE" && item.SOURCE_URL ? extractYouTubeVideoId(item.SOURCE_URL) : null;
return (
<AuthGuard>
<NavBar />
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Back link */}
<button
onClick={() => router.push("/knowledge")}
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
>
Back to Knowledge
</button>
{/* Header */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
<div className="flex items-center gap-3 mb-3">
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
{typeLabels[item.TYPE] || item.TYPE}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
{item.STATUS}
</span>
</div>
{/* Title (editable) */}
{editingTitle ? (
<div className="flex gap-2 mb-3">
<input
type="text"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSaveTitle()}
className="flex-1 px-3 py-1 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-lg font-bold"
autoFocus
/>
<button
onClick={handleSaveTitle}
className="px-3 py-1 text-sm bg-[var(--color-primary)] rounded-lg"
>
</button>
<button
onClick={() => setEditingTitle(false)}
className="px-3 py-1 text-sm bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg"
>
</button>
</div>
) : (
<h1
className="text-xl font-bold mb-3 cursor-pointer hover:text-[var(--color-primary)] transition-colors"
onClick={() => {
setTitleDraft(item.TITLE || "");
setEditingTitle(true);
}}
title="클릭하여 제목 수정"
>
{item.TITLE || "Untitled"}
</h1>
)}
{/* Source URL */}
{item.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] mb-3 break-all">
<a href={item.SOURCE_URL} target="_blank" rel="noopener noreferrer" className="hover:text-[var(--color-primary)]">
{item.SOURCE_URL}
</a>
</p>
)}
{/* Meta */}
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>: {new Date(item.CREATED_AT).toLocaleString("ko-KR")}</span>
<span>: {new Date(item.UPDATED_AT).toLocaleString("ko-KR")}</span>
</div>
</div>
{/* YouTube Embed */}
{videoId && (
<div className="rounded-xl overflow-hidden border border-[var(--color-border)] mb-6">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
className="absolute inset-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
{/* Processing indicator */}
{/* Categories */}
{item.CATEGORIES && item.CATEGORIES.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{item.CATEGORIES.map((cat) => (
<span
key={cat.ID}
className="text-xs px-2.5 py-1 rounded-full bg-[var(--color-primary)]/15 text-[var(--color-primary)] border border-[var(--color-primary)]/30"
>
{cat.FULL_PATH}
</span>
))}
</div>
)}
{["PENDING", "EXTRACTING", "CHUNKING", "CATEGORIZING"].includes(item.STATUS) && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-blue-400">
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
</span>
</div>
)}
{/* Chunks toggle */}
{item.STATUS === "READY" && (
<div className="mb-6">
<button
onClick={handleToggleChunks}
className="text-sm text-[var(--color-primary)] hover:underline"
>
{showChunks ? "▼ 청크 숨기기" : "▶ 청크 보기"}
</button>
{showChunks && (
<div className="mt-3 space-y-3">
{chunks.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)]">Loading chunks...</p>
) : (
chunks.map((chunk) => (
<div
key={chunk.ID}
className="bg-[var(--color-bg-card)] rounded-lg p-4 border border-[var(--color-border)]"
>
<div className="flex justify-between items-center mb-2">
<span className="text-xs text-[var(--color-text-muted)]">
Chunk #{chunk.CHUNK_INDEX}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
~{chunk.TOKEN_COUNT} tokens
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{chunk.CONTENT}</p>
</div>
))
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
{item.STATUS === "READY" && (
<button
onClick={async () => {
setGenerating(true);
try {
const result = await request<{ generated: number }>({
method: "POST",
url: `/api/study-cards/generate/${id}`,
});
alert(`${result.generated}개의 스터디 카드가 생성되었습니다.`);
} catch (err) {
console.error("Failed to generate cards:", err);
alert("카드 생성에 실패했습니다.");
} finally {
setGenerating(false);
}
}}
disabled={generating}
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
>
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
</button>
)}
<button
onClick={handleDelete}
disabled={deleting}
className="text-sm text-red-400 hover:text-red-300 disabled:opacity-40"
>
{deleting ? "삭제 중..." : "삭제"}
</button>
</div>
</main>
</AuthGuard>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import { useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
type KnowledgeType = "TEXT" | "WEB" | "YOUTUBE";
interface ModelInfo {
id: string;
name: string;
vendor: string;
}
function extractYouTubeVideoId(url: string): string | null {
try {
const u = new URL(url);
if (u.hostname === "youtu.be") return u.pathname.slice(1);
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
} catch {
// invalid URL
}
return null;
}
export default function KnowledgeAddPage() {
const { request } = useApi();
const router = useRouter();
const [type, setType] = useState<KnowledgeType>("TEXT");
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [rawText, setRawText] = useState("");
const [modelId, setModelId] = useState("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
request<{ models: ModelInfo[]; defaultModel: string; configured: boolean }>({
method: "GET",
url: "/api/models",
}).then((data) => {
setModels(data.models);
setModelId(data.defaultModel);
}).catch((err) => {
console.error("Failed to load models:", err);
});
}, []);
const videoId = useMemo(() => (type === "YOUTUBE" ? extractYouTubeVideoId(url) : null), [type, url]);
const canSubmit =
!submitting &&
((type === "TEXT" && rawText.trim().length > 0) ||
(type === "WEB" && url.trim().length > 0) ||
(type === "YOUTUBE" && url.trim().length > 0 && rawText.trim().length > 0));
const handleSubmit = async () => {
setError(null);
setSubmitting(true);
try {
await request({
method: "POST",
url: "/api/knowledge/ingest",
data: {
type,
title: title.trim() || null,
url: type !== "TEXT" ? url.trim() : null,
rawText: type !== "WEB" ? rawText.trim() : null,
modelId: modelId || null,
},
});
router.push("/knowledge");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Failed to submit";
setError(msg);
setSubmitting(false);
}
};
const types: { value: KnowledgeType; label: string }[] = [
{ value: "TEXT", label: "Text" },
{ value: "WEB", label: "Web" },
{ value: "YOUTUBE", label: "YouTube" },
];
// 벤더별 그룹화
const groupedModels = useMemo(() => {
const groups: Record<string, ModelInfo[]> = {};
for (const m of models) {
if (!groups[m.vendor]) groups[m.vendor] = [];
groups[m.vendor].push(m);
}
return groups;
}, [models]);
return (
<AuthGuard>
<NavBar />
<main className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Add Knowledge</h1>
{/* Type Tabs */}
<div className="flex gap-2 mb-6">
{types.map((t) => (
<button
key={t.value}
onClick={() => {
setType(t.value);
setUrl("");
setRawText("");
setError(null);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
type === t.value
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
( AI가 )
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="비워두면 내용 기반으로 자동 생성"
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
{/* URL (WEB / YOUTUBE) */}
{type !== "TEXT" && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={type === "YOUTUBE" ? "https://www.youtube.com/watch?v=..." : "https://example.com/article"}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
</div>
)}
{/* YouTube Embed */}
{type === "YOUTUBE" && videoId && (
<div className="rounded-lg overflow-hidden border border-[var(--color-border)]">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
className="absolute inset-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
{/* Text Input (TEXT / YOUTUBE) */}
{type !== "WEB" && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
{type === "YOUTUBE" ? "Transcript / 내용 붙여넣기" : "텍스트 입력"}
</label>
<textarea
value={rawText}
onChange={(e) => setRawText(e.target.value)}
placeholder={
type === "YOUTUBE"
? "영상의 transcript나 내용을 여기에 붙여넣으세요..."
: "텍스트를 직접 입력하세요..."
}
rows={12}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y"
/>
</div>
)}
{/* Model Selection */}
{models.length > 0 && (
<div>
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
AI
</label>
<select
value={modelId}
onChange={(e) => setModelId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
>
{Object.entries(groupedModels).map(([vendor, vendorModels]) => (
<optgroup key={vendor} label={vendor}>
{vendorModels.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</optgroup>
))}
</select>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{submitting ? "처리 중..." : "추가"}
</button>
<button
onClick={() => router.push("/knowledge")}
className="px-6 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors"
>
</button>
</div>
</div>
</main>
</AuthGuard>
);
}

View File

@@ -1,22 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface KnowledgeItem {
ID: string;
TYPE: string;
TITLE: string;
SOURCE_URL: string;
STATUS: string;
CREATED_AT: string;
}
const statusColors: Record<string, string> = {
PENDING: "bg-yellow-500/20 text-yellow-400",
EXTRACTING: "bg-blue-500/20 text-blue-400",
CHUNKING: "bg-purple-500/20 text-purple-400",
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
READY: "bg-green-500/20 text-green-400",
FAILED: "bg-red-500/20 text-red-400",
};
const typeLabels: Record<string, string> = {
YOUTUBE: "YouTube",
WEB: "Web",
TEXT: "Text",
};
export default function KnowledgePage() {
const { request } = useApi();
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchItems = async () => {
try {
const data = await request<KnowledgeItem[]>({ method: "GET", url: "/api/knowledge" });
setItems(data);
} catch (err) {
console.error("Failed to load knowledge items:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchItems();
// Poll for status updates every 5 seconds
const interval = setInterval(fetchItems, 5000);
return () => clearInterval(interval);
}, []);
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Knowledge</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
<Link
href="/knowledge/add"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
>
+ Add Knowledge
</button>
</Link>
</div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : items.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<Link
key={item.ID}
href={`/knowledge/${item.ID}`}
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
{typeLabels[item.TYPE] || item.TYPE}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
{item.STATUS}
</span>
</div>
<h3 className="font-medium truncate">
{item.TITLE || item.SOURCE_URL || "Untitled"}
</h3>
{item.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(item.CREATED_AT).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
)}
</main>
</AuthGuard>
);

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
function CallbackHandler() {
const router = useRouter();
const searchParams = useSearchParams();
const { login } = useAuth();
const processed = useRef(false);
useEffect(() => {
if (processed.current) return;
processed.current = true;
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error) {
console.error("OAuth error:", error);
router.replace("/login");
return;
}
if (!code) {
router.replace("/login");
return;
}
api.post("/api/auth/google", { code })
.then((res) => {
login(res.data);
router.replace("/dashboard");
})
.catch((err) => {
console.error("Login failed:", err);
router.replace("/login");
});
}, [searchParams, login, router]);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
<p className="text-[var(--color-text-muted)]">Signing in...</p>
</div>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</div>
}>
<CallbackHandler />
</Suspense>
);
}

View File

@@ -1,25 +1,32 @@
"use client";
import { useState } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const REDIRECT_URI = `${typeof window !== "undefined" ? window.location.origin : ""}/login/callback`;
export default function LoginPage() {
const router = useRouter();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const { isAuthenticated, isLoading } = useAuth();
const handleGoogleLogin = async () => {
setIsLoading(true);
try {
// TODO: Implement Google OAuth flow
// For now, placeholder
alert("Google OAuth not configured yet");
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsLoading(false);
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace("/dashboard");
}
}, [isAuthenticated, isLoading, router]);
const handleGoogleLogin = () => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
prompt: "consent",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
};
return (
@@ -33,8 +40,7 @@ export default function LoginPage() {
</div>
<button
onClick={handleGoogleLogin}
disabled={isLoading}
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center justify-center gap-3"
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors flex items-center justify-center gap-3"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
@@ -42,7 +48,7 @@ export default function LoginPage() {
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
{isLoading ? "Signing in..." : "Sign in with Google"}
Sign in with Google
</button>
</div>
</div>

View File

@@ -1,17 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface StudyCard {
ID: string;
KNOWLEDGE_ITEM_ID: string | null;
KNOWLEDGE_TITLE: string | null;
FRONT: string;
BACK: string;
EASE_FACTOR: number;
INTERVAL_DAYS: number;
REPETITIONS: number;
}
const ratingButtons = [
{ value: 0, label: "모름", color: "bg-red-600 hover:bg-red-500" },
{ value: 1, label: "거의 모름", color: "bg-red-500 hover:bg-red-400" },
{ value: 2, label: "어려움", color: "bg-orange-500 hover:bg-orange-400" },
{ value: 3, label: "보통", color: "bg-yellow-500 hover:bg-yellow-400" },
{ value: 4, label: "쉬움", color: "bg-green-500 hover:bg-green-400" },
{ value: 5, label: "완벽", color: "bg-green-600 hover:bg-green-500" },
];
export default function StudyPage() {
const { request } = useApi();
const [cards, setCards] = useState<StudyCard[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showBack, setShowBack] = useState(false);
const [reviewing, setReviewing] = useState(false);
const fetchDueCards = async () => {
try {
const data = await request<StudyCard[]>({ method: "GET", url: "/api/study-cards/due" });
setCards(data);
setCurrentIndex(0);
setShowBack(false);
} catch (err) {
console.error("Failed to load cards:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDueCards();
}, []);
const handleReview = async (rating: number) => {
if (reviewing || !cards[currentIndex]) return;
setReviewing(true);
try {
await request({
method: "POST",
url: `/api/study-cards/${cards[currentIndex].ID}/review`,
data: { rating },
});
if (currentIndex + 1 < cards.length) {
setCurrentIndex(currentIndex + 1);
setShowBack(false);
} else {
// 모든 카드 완료
setCards([]);
}
} catch (err) {
console.error("Failed to review card:", err);
} finally {
setReviewing(false);
}
};
const currentCard = cards[currentIndex];
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Study Cards</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] flex items-center justify-center min-h-[40vh]">
<p className="text-[var(--color-text-muted)]">No cards due for review. Generate cards from your knowledge items.</p>
<main className="max-w-2xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Study Cards</h1>
{cards.length > 0 && (
<span className="text-sm text-[var(--color-text-muted)]">
{currentIndex + 1} / {cards.length}
</span>
)}
</div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : cards.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-8 border border-[var(--color-border)] text-center">
<p className="text-lg mb-2">
{currentIndex > 0 ? "복습 완료!" : "복습할 카드가 없습니다."}
</p>
<p className="text-[var(--color-text-muted)] text-sm">
Knowledge .
</p>
</div>
) : currentCard ? (
<div>
{/* Source */}
{currentCard.KNOWLEDGE_TITLE && (
<p className="text-xs text-[var(--color-text-muted)] mb-2">
: {currentCard.KNOWLEDGE_TITLE}
</p>
)}
{/* Card */}
<div
className="bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] min-h-[300px] flex flex-col cursor-pointer"
onClick={() => !showBack && setShowBack(true)}
>
{/* Front */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<p className="text-xs text-[var(--color-text-muted)] mb-3">Question</p>
<p className="text-lg whitespace-pre-wrap">{currentCard.FRONT}</p>
</div>
</div>
{/* Back (revealed) */}
{showBack && (
<div className="border-t border-[var(--color-border)] flex-1 flex items-center justify-center p-8 bg-[var(--color-bg-hover)]">
<div className="text-center">
<p className="text-xs text-[var(--color-text-muted)] mb-3">Answer</p>
<p className="text-lg whitespace-pre-wrap">{currentCard.BACK}</p>
</div>
</div>
)}
</div>
{/* Actions */}
{!showBack ? (
<div className="mt-4 text-center">
<button
onClick={() => setShowBack(true)}
className="px-8 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl transition-colors"
>
</button>
</div>
) : (
<div className="mt-4">
<p className="text-sm text-[var(--color-text-muted)] text-center mb-3">
?
</p>
<div className="grid grid-cols-3 gap-2">
{ratingButtons.map((btn) => (
<button
key={btn.value}
onClick={() => handleReview(btn.value)}
disabled={reviewing}
className={`${btn.color} text-white py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40`}
>
{btn.label}
</button>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mt-4 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)] transition-all"
style={{ width: `${((currentIndex) / cards.length) * 100}%` }}
/>
</div>
</div>
) : null}
</main>
</AuthGuard>
);

View File

@@ -1,22 +1,367 @@
"use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Todo {
ID: string;
PARENT_ID: string | null;
TITLE: string;
DESCRIPTION: string | null;
STATUS: string;
PRIORITY: string;
DUE_DATE: string | null;
SUBTASK_COUNT: number;
SUBTASK_DONE_COUNT: number;
}
interface Subtask {
ID: string;
TITLE: string;
STATUS: string;
}
const priorityColors: Record<string, string> = {
HIGH: "text-red-400",
MEDIUM: "text-yellow-400",
LOW: "text-green-400",
};
const priorityLabels: Record<string, string> = {
HIGH: "높음",
MEDIUM: "보통",
LOW: "낮음",
};
export default function TodosPage() {
const { request } = useApi();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"ALL" | "PENDING" | "DONE">("ALL");
// Add form
const [showAdd, setShowAdd] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newPriority, setNewPriority] = useState("MEDIUM");
const [newDueDate, setNewDueDate] = useState("");
// Subtasks
const [expandedId, setExpandedId] = useState<string | null>(null);
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
const [newSubtask, setNewSubtask] = useState("");
const fetchTodos = async () => {
try {
const status = filter === "ALL" ? undefined : filter;
const params = new URLSearchParams();
if (status) params.set("status", status);
const data = await request<Todo[]>({ method: "GET", url: `/api/todos?${params}` });
setTodos(data);
} catch (err) {
console.error("Failed to load todos:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTodos();
}, [filter]);
const handleAdd = async () => {
if (!newTitle.trim()) return;
try {
await request({
method: "POST",
url: "/api/todos",
data: {
title: newTitle.trim(),
priority: newPriority,
dueDate: newDueDate || null,
},
});
setNewTitle("");
setNewDueDate("");
setShowAdd(false);
fetchTodos();
} catch (err) {
console.error("Failed to create todo:", err);
}
};
const handleToggleStatus = async (todo: Todo) => {
const newStatus = todo.STATUS === "DONE" ? "PENDING" : "DONE";
try {
await request({
method: "PATCH",
url: `/api/todos/${todo.ID}`,
data: { status: newStatus },
});
fetchTodos();
if (expandedId === todo.ID) fetchSubtasks(todo.ID);
} catch (err) {
console.error("Failed to update todo:", err);
}
};
const handleDelete = async (id: string) => {
try {
await request({ method: "DELETE", url: `/api/todos/${id}` });
if (expandedId === id) setExpandedId(null);
fetchTodos();
} catch (err) {
console.error("Failed to delete todo:", err);
}
};
const fetchSubtasks = async (todoId: string) => {
try {
const data = await request<Subtask[]>({ method: "GET", url: `/api/todos/${todoId}/subtasks` });
setSubtasks(data);
} catch (err) {
console.error("Failed to load subtasks:", err);
}
};
const handleExpand = (todoId: string) => {
if (expandedId === todoId) {
setExpandedId(null);
return;
}
setExpandedId(todoId);
fetchSubtasks(todoId);
};
const handleAddSubtask = async () => {
if (!newSubtask.trim() || !expandedId) return;
try {
await request({
method: "POST",
url: "/api/todos",
data: { title: newSubtask.trim(), priority: "MEDIUM", parentId: expandedId },
});
setNewSubtask("");
fetchSubtasks(expandedId);
fetchTodos();
} catch (err) {
console.error("Failed to create subtask:", err);
}
};
const handleToggleSubtask = async (subtask: Subtask) => {
const newStatus = subtask.STATUS === "DONE" ? "PENDING" : "DONE";
try {
await request({
method: "PATCH",
url: `/api/todos/${subtask.ID}`,
data: { status: newStatus },
});
if (expandedId) fetchSubtasks(expandedId);
fetchTodos();
} catch (err) {
console.error("Failed to update subtask:", err);
}
};
const isOverdue = (dueDate: string | null) => {
if (!dueDate) return false;
return new Date(dueDate) < new Date(new Date().toDateString());
};
return (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Todos</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
<button
onClick={() => setShowAdd(!showAdd)}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+ Add Todo
</button>
</div>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No todos yet. Create your first task.</p>
{/* Add form */}
{showAdd && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="할 일 입력..."
autoFocus
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
<div className="flex gap-3 items-center">
<select
value={newPriority}
onChange={(e) => setNewPriority(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
<option value="LOW"></option>
</select>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
/>
<button
onClick={handleAdd}
disabled={!newTitle.trim()}
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
>
</button>
</div>
</div>
)}
{/* Filters */}
<div className="flex gap-2 mb-4">
{(["ALL", "PENDING", "DONE"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${
filter === f
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)]"
}`}
>
{f === "ALL" ? "전체" : f === "PENDING" ? "진행중" : "완료"}
</button>
))}
</div>
{/* Todo list */}
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : todos.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">
{filter === "ALL" ? "아직 할 일이 없습니다." : filter === "PENDING" ? "진행중인 할 일이 없습니다." : "완료된 할 일이 없습니다."}
</p>
</div>
) : (
<div className="space-y-2">
{todos.map((todo) => (
<div key={todo.ID}>
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors">
<div className="flex items-center gap-3">
{/* Checkbox */}
<button
onClick={() => handleToggleStatus(todo)}
className={`w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
todo.STATUS === "DONE"
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{todo.STATUS === "DONE" && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`${todo.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
{todo.TITLE}
</span>
<span className={`text-xs ${priorityColors[todo.PRIORITY]}`}>
{priorityLabels[todo.PRIORITY]}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
{todo.DUE_DATE && (
<span className={`text-xs ${isOverdue(todo.DUE_DATE) && todo.STATUS !== "DONE" ? "text-red-400" : "text-[var(--color-text-muted)]"}`}>
{todo.DUE_DATE}
</span>
)}
{todo.SUBTASK_COUNT > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
{todo.SUBTASK_DONE_COUNT}/{todo.SUBTASK_COUNT} subtasks
</span>
)}
</div>
</div>
{/* Actions */}
<button
onClick={() => handleExpand(todo.ID)}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)] px-2"
>
{expandedId === todo.ID ? "▼" : "▶"}
</button>
<button
onClick={() => handleDelete(todo.ID)}
className="text-xs text-red-400 hover:text-red-300 px-1"
>
</button>
</div>
</div>
{/* Subtasks */}
{expandedId === todo.ID && (
<div className="ml-8 mt-1 space-y-1">
{subtasks.map((st) => (
<div
key={st.ID}
className="flex items-center gap-3 bg-[var(--color-bg-card)] rounded-lg px-3 py-2 border border-[var(--color-border)]"
>
<button
onClick={() => handleToggleSubtask(st)}
className={`w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
st.STATUS === "DONE"
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{st.STATUS === "DONE" && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={`text-sm flex-1 ${st.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
{st.TITLE}
</span>
</div>
))}
{/* Add subtask */}
<div className="flex gap-2">
<input
type="text"
value={newSubtask}
onChange={(e) => setNewSubtask(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddSubtask()}
placeholder="서브태스크 추가..."
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
<button
onClick={handleAddSubtask}
disabled={!newSubtask.trim()}
className="px-3 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-xs transition-colors"
>
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</main>
</AuthGuard>
);

View File

@@ -3,7 +3,7 @@
import { useCallback } from "react";
import { api, LoginResponse } from "./api";
import { useAuth } from "./auth-context";
import { AxiosRequestConfig } from "axios";
import { AxiosRequestConfig, AxiosError } from "axios";
export function useApi() {
const { setAccessToken, logout } = useAuth();
@@ -13,8 +13,9 @@ export function useApi() {
try {
const response = await api.request<T>(config);
return response.data;
} catch (error: any) {
if (error.response?.status === 401) {
} catch (error: unknown) {
const axiosError = error as AxiosError;
if (axiosError.response?.status === 401) {
try {
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
setAccessToken(refreshRes.data.accessToken);