diff --git a/.env.sample b/.env.sample index d6c41f5..3cb8706 100644 --- a/.env.sample +++ b/.env.sample @@ -5,17 +5,25 @@ # ============================================ # --------------------- -# Database (Oracle 23ai) +# Database (Oracle ADB) # --------------------- -DB_HOST=localhost -DB_SERVICE=FREEPDB1 -DB_USER=sundol -DB_PASSWORD= +ORACLE_WALLET_PATH=/path/to/wallet +ORACLE_TNS_NAME=dbdf0q9672liizzd_medium +ORACLE_USERNAME=admin +ORACLE_PASSWORD= # --------------------- # JWT # --------------------- JWT_SECRET= +JWT_ACCESS_TOKEN_EXPIRY=900000 +JWT_REFRESH_TOKEN_EXPIRY=604800000 + +# --------------------- +# Server +# --------------------- +SERVER_PORT=8080 +CORS_ORIGIN=http://localhost:3000 # --------------------- # Google OAuth @@ -27,7 +35,7 @@ GOOGLE_CLIENT_SECRET= # OCI GenAI # --------------------- OCI_COMPARTMENT_ID= -OCI_REGION=us-chicago-1 +OCI_REGION=ap-seoul-1 # --------------------- # YouTube (optional) @@ -47,9 +55,3 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET= API_URL=http://localhost:8080 - -# --------------------- -# Gitea -# --------------------- -GITEA_USER=joungmin -GITEA_PASSWORD= diff --git a/CLAUDE.md b/CLAUDE.md index 607b973..2b88955 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,14 +23,15 @@ # DB 접속 (Oracle Autonomous DB - SQLcl) -- 환경변수 파일: `/Users/joungminko/devkit/account_manager/.env` -- SQLcl 실행: +- 환경변수 파일: `.env` (프로젝트 루트) +- SQLcl 실행 (DB 작업은 반드시 SQLcl을 통해 수행): ```bash -export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home -export TNS_ADMIN=/Users/joungminko/devkit/db_conn/Wallet_WKW7PT1B3PIK6DTI -/opt/homebrew/Caskroom/sqlcl/25.4.2.044.1837/sqlcl/bin/sql admin/Dhfkzmf#12345@wkw7pt1b3pik6dti_medium +# .env에서 ORACLE_WALLET_PATH, ORACLE_TNS_NAME, ORACLE_USERNAME, ORACLE_PASSWORD 참조 +set -a && source .env && set +a +sql ${ORACLE_USERNAME}/${ORACLE_PASSWORD}@${ORACLE_TNS_NAME}?TNS_ADMIN=${ORACLE_WALLET_PATH} ``` - DDL 변경이 필요하면 SQLcl로 직접 ALTER TABLE 실행할 것 +- 테이블 생성/변경 등 모든 DB 스키마 작업은 SQLcl을 통해 수행 # 코드 설계 원칙 diff --git a/db/migration/V1__create_tables.sql b/db/migration/V1__create_tables.sql new file mode 100644 index 0000000..fd762e4 --- /dev/null +++ b/db/migration/V1__create_tables.sql @@ -0,0 +1,156 @@ +-- ============================================ +-- SUNDOL Database Schema - Oracle 23ai +-- ============================================ + +-- USERS +CREATE TABLE users ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + email VARCHAR2(320) NOT NULL UNIQUE, + display_name VARCHAR2(255), + avatar_url VARCHAR2(1000), + google_sub VARCHAR2(255) UNIQUE, + refresh_token VARCHAR2(1000), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +-- KNOWLEDGE_ITEMS +CREATE TABLE knowledge_items ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + type VARCHAR2(20) NOT NULL, + title VARCHAR2(500), + source_url VARCHAR2(2000), + raw_text CLOB, + status VARCHAR2(20) DEFAULT 'PENDING' NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_ki_user_id ON knowledge_items(user_id); +CREATE INDEX idx_ki_status ON knowledge_items(status); + +-- KNOWLEDGE_CHUNKS +CREATE TABLE knowledge_chunks ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + knowledge_item_id RAW(16) NOT NULL REFERENCES knowledge_items(id) ON DELETE CASCADE, + chunk_index NUMBER(10) NOT NULL, + content CLOB NOT NULL, + embedding VECTOR(1024, FLOAT32), + token_count NUMBER(10), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_kc_item_id ON knowledge_chunks(knowledge_item_id); + +-- VECTOR index for semantic search +CREATE VECTOR INDEX idx_kc_embedding ON knowledge_chunks(embedding) + ORGANIZATION NEIGHBOR PARTITIONS + DISTANCE COSINE + WITH TARGET ACCURACY 95; + +-- TAGS +CREATE TABLE tags ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + name VARCHAR2(100) NOT NULL, + color VARCHAR2(7) DEFAULT '#3b82f6', + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX idx_tags_user_name ON tags(user_id, name); + +-- KNOWLEDGE_ITEM_TAGS (many-to-many) +CREATE TABLE knowledge_item_tags ( + knowledge_item_id RAW(16) NOT NULL REFERENCES knowledge_items(id) ON DELETE CASCADE, + tag_id RAW(16) NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (knowledge_item_id, tag_id) +); + +-- CHAT_SESSIONS +CREATE TABLE chat_sessions ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + title VARCHAR2(500) DEFAULT 'New Chat', + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_cs_user_id ON chat_sessions(user_id); + +-- CHAT_MESSAGES +CREATE TABLE chat_messages ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + session_id RAW(16) NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE, + role VARCHAR2(20) NOT NULL, + content CLOB NOT NULL, + source_chunks CLOB, + tokens_used NUMBER(10), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_cm_session_id ON chat_messages(session_id); + +-- TODOS +CREATE TABLE todos ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + parent_id RAW(16) REFERENCES todos(id) ON DELETE CASCADE, + title VARCHAR2(500) NOT NULL, + description CLOB, + status VARCHAR2(20) DEFAULT 'PENDING' NOT NULL, + priority VARCHAR2(10) DEFAULT 'MEDIUM' NOT NULL, + due_date DATE, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_todos_user_id ON todos(user_id); +CREATE INDEX idx_todos_parent_id ON todos(parent_id); + +-- HABITS +CREATE TABLE habits ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + name VARCHAR2(200) NOT NULL, + description VARCHAR2(1000), + habit_type VARCHAR2(10) DEFAULT 'BUILD' NOT NULL, + target_days VARCHAR2(50) DEFAULT 'DAILY', + color VARCHAR2(7) DEFAULT '#22c55e', + streak_current NUMBER(10) DEFAULT 0, + streak_best NUMBER(10) DEFAULT 0, + is_active NUMBER(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_habits_user_id ON habits(user_id); + +-- HABIT_LOGS +CREATE TABLE habit_logs ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + habit_id RAW(16) NOT NULL REFERENCES habits(id) ON DELETE CASCADE, + log_date DATE NOT NULL, + checked_in NUMBER(1) DEFAULT 1, + note VARCHAR2(500), + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX idx_hl_habit_date ON habit_logs(habit_id, log_date); + +-- STUDY_CARDS +CREATE TABLE study_cards ( + id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY, + user_id RAW(16) NOT NULL REFERENCES users(id), + knowledge_item_id RAW(16) REFERENCES knowledge_items(id) ON DELETE SET NULL, + front CLOB NOT NULL, + back CLOB NOT NULL, + ease_factor NUMBER(4,2) DEFAULT 2.50, + interval_days NUMBER(10) DEFAULT 0, + repetitions NUMBER(10) DEFAULT 0, + next_review_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT SYSTIMESTAMP NOT NULL +); + +CREATE INDEX idx_sc_user_review ON study_cards(user_id, next_review_at); diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..f0d7220 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +echo "=== SUNDOL Deploy ===" + +# Load env +set -a +source .env +set +a + +# Build backend +echo "[1/4] Building backend..." +cd sundol-backend +mvn package -q -DskipTests +cd .. + +# Build frontend +echo "[2/4] Building frontend..." +cd sundol-frontend +npm run build +cd .. + +# Git push +echo "[3/4] Pushing to git..." +git add -A +git commit -m "Deploy $(date +%Y%m%d-%H%M%S)" || true +git push origin main + +# Restart PM2 +echo "[4/4] Restarting services..." +pm2 restart ecosystem.config.cjs + +echo "=== Deploy complete ===" diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..4521490 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,23 @@ +module.exports = { + apps: [ + { + name: "sundol-backend", + script: "./start-backend.sh", + interpreter: "/bin/bash", + cwd: "/home/opc/sundol", + env: { + JAVA_HOME: "/usr/lib/jvm/java-21", + }, + }, + { + name: "sundol-frontend", + script: "node", + args: "sundol-frontend/.next/standalone/server.js", + cwd: "/home/opc/sundol", + env: { + PORT: 3000, + HOSTNAME: "0.0.0.0", + }, + }, + ], +}; diff --git a/start-backend.sh b/start-backend.sh new file mode 100755 index 0000000..177241e --- /dev/null +++ b/start-backend.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -a +source /home/opc/sundol/.env +set +a + +JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21} +export JAVA_HOME + +exec $JAVA_HOME/bin/java -jar /home/opc/sundol/sundol-backend/target/sundol-backend-0.0.1-SNAPSHOT.jar diff --git a/sundol-backend/pom.xml b/sundol-backend/pom.xml new file mode 100644 index 0000000..deaa488 --- /dev/null +++ b/sundol-backend/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.4 + + + + com.sundol + sundol-backend + 0.0.1-SNAPSHOT + sundol-backend + SUNDOL Backend + + + 21 + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + com.oracle.database.jdbc + ojdbc11 + + + com.oracle.database.jdbc + ucp + + + com.oracle.database.security + oraclepki + + + com.oracle.database.security + osdt_cert + + + com.oracle.database.security + osdt_core + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + + org.projectlombok + lombok + true + + + + + org.jsoup + jsoup + 1.18.3 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.sundol.SundolApplication + + + org.projectlombok + lombok + + + + + + + diff --git a/sundol-backend/src/main/java/com/sundol/SundolApplication.java b/sundol-backend/src/main/java/com/sundol/SundolApplication.java new file mode 100644 index 0000000..64ef679 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/SundolApplication.java @@ -0,0 +1,15 @@ +package com.sundol; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@EnableScheduling +@EnableAsync +public class SundolApplication { + public static void main(String[] args) { + SpringApplication.run(SundolApplication.class, args); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/config/DatabaseConfig.java b/sundol-backend/src/main/java/com/sundol/config/DatabaseConfig.java new file mode 100644 index 0000000..08db3d6 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/config/DatabaseConfig.java @@ -0,0 +1,46 @@ +package com.sundol.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; + +@Configuration +public class DatabaseConfig { + + @Value("${oracle.wallet-path}") + private String walletPath; + + @Value("${oracle.tns-name}") + private String tnsName; + + @Value("${oracle.username}") + private String username; + + @Value("${oracle.password}") + private String password; + + @Bean + public DataSource dataSource() { + HikariConfig config = new HikariConfig(); + config.setDriverClassName("oracle.jdbc.OracleDriver"); + config.setJdbcUrl("jdbc:oracle:thin:@" + tnsName + "?TNS_ADMIN=" + walletPath); + config.setUsername(username); + config.setPassword(password); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + return new HikariDataSource(config); + } + + @Bean + public JdbcTemplate jdbcTemplate(DataSource dataSource) { + return new JdbcTemplate(dataSource); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/config/SecurityConfig.java b/sundol-backend/src/main/java/com/sundol/config/SecurityConfig.java new file mode 100644 index 0000000..813c05c --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/config/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.sundol.config; + +import com.sundol.security.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Value("${cors.origin}") + private String corsOrigin; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authorizeExchange(exchanges -> exchanges + .pathMatchers("/api/auth/**").permitAll() + .pathMatchers(HttpMethod.OPTIONS).permitAll() + .anyExchange().authenticated() + ) + .addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of(corsOrigin)); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/AuthController.java b/sundol-backend/src/main/java/com/sundol/controller/AuthController.java new file mode 100644 index 0000000..f60bfa1 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/AuthController.java @@ -0,0 +1,38 @@ +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.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/google") + public Mono> googleLogin(@RequestBody LoginRequest request) { + return authService.googleLogin(request.idToken()) + .map(ResponseEntity::ok); + } + + @PostMapping("/refresh") + public Mono> refresh(@RequestBody RefreshRequest request) { + return authService.refresh(request.refreshToken()) + .map(ResponseEntity::ok); + } + + @PostMapping("/logout") + public Mono> logout(@RequestAttribute("userId") String userId) { + return authService.logout(userId) + .then(Mono.just(ResponseEntity.ok().build())); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/ChatController.java b/sundol-backend/src/main/java/com/sundol/controller/ChatController.java new file mode 100644 index 0000000..95db42b --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/ChatController.java @@ -0,0 +1,61 @@ +package com.sundol.controller; + +import com.sundol.dto.ChatMessageRequest; +import com.sundol.service.ChatService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/chat") +public class ChatController { + + private final ChatService chatService; + + public ChatController(ChatService chatService) { + this.chatService = chatService; + } + + @GetMapping("/sessions") + public Mono>>> listSessions( + @AuthenticationPrincipal String userId) { + return chatService.listSessions(userId) + .map(ResponseEntity::ok); + } + + @PostMapping("/sessions") + public Mono>> createSession( + @AuthenticationPrincipal String userId) { + return chatService.createSession(userId) + .map(ResponseEntity::ok); + } + + @GetMapping("/sessions/{id}/messages") + public Mono>>> getMessages( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return chatService.getMessages(userId, id) + .map(ResponseEntity::ok); + } + + @PostMapping("/sessions/{id}/messages") + public Mono>> sendMessage( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody ChatMessageRequest request) { + return chatService.sendMessage(userId, id, request.content()) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/sessions/{id}") + public Mono> deleteSession( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return chatService.deleteSession(userId, id) + .then(Mono.just(ResponseEntity.ok().build())); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/HabitController.java b/sundol-backend/src/main/java/com/sundol/controller/HabitController.java new file mode 100644 index 0000000..7c0a0e1 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/HabitController.java @@ -0,0 +1,74 @@ +package com.sundol.controller; + +import com.sundol.dto.HabitRequest; +import com.sundol.service.HabitService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/habits") +public class HabitController { + + private final HabitService habitService; + + public HabitController(HabitService habitService) { + this.habitService = habitService; + } + + @GetMapping + public Mono>>> list( + @AuthenticationPrincipal String userId) { + return habitService.list(userId) + .map(ResponseEntity::ok); + } + + @PostMapping + public Mono>> create( + @AuthenticationPrincipal String userId, + @RequestBody HabitRequest request) { + return habitService.create(userId, request) + .map(ResponseEntity::ok); + } + + @PatchMapping("/{id}") + public Mono>> update( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody Map updates) { + return habitService.update(userId, id, updates) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> delete( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return habitService.delete(userId, id) + .then(Mono.just(ResponseEntity.ok().build())); + } + + @PostMapping("/{id}/checkin") + public Mono>> checkin( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody(required = false) Map body) { + String note = body != null ? body.get("note") : null; + return habitService.checkin(userId, id, note) + .map(ResponseEntity::ok); + } + + @GetMapping("/{id}/logs") + public Mono>>> getLogs( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + return habitService.getLogs(userId, id, from, to) + .map(ResponseEntity::ok); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java new file mode 100644 index 0000000..4b535bc --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java @@ -0,0 +1,75 @@ +package com.sundol.controller; + +import com.sundol.dto.IngestRequest; +import com.sundol.service.KnowledgeService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/knowledge") +public class KnowledgeController { + + private final KnowledgeService knowledgeService; + + public KnowledgeController(KnowledgeService knowledgeService) { + this.knowledgeService = knowledgeService; + } + + @GetMapping + public Mono>>> list( + @AuthenticationPrincipal String userId, + @RequestParam(required = false) String type, + @RequestParam(required = false) String status, + @RequestParam(required = false) String tag, + @RequestParam(required = false) String search) { + return knowledgeService.list(userId, type, status, tag, search) + .map(ResponseEntity::ok); + } + + @PostMapping("/ingest") + public Mono>> ingest( + @AuthenticationPrincipal String userId, + @RequestBody IngestRequest request) { + return knowledgeService.ingest(userId, request) + .map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result)); + } + + @GetMapping("/{id}") + public Mono>> getById( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return knowledgeService.getById(userId, id) + .map(ResponseEntity::ok); + } + + @PatchMapping("/{id}") + public Mono>> update( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody Map updates) { + return knowledgeService.update(userId, id, updates) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> delete( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return knowledgeService.delete(userId, id) + .then(Mono.just(ResponseEntity.ok().build())); + } + + @GetMapping("/{id}/chunks") + public Mono>>> getChunks( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return knowledgeService.getChunks(userId, id) + .map(ResponseEntity::ok); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/SearchController.java b/sundol-backend/src/main/java/com/sundol/controller/SearchController.java new file mode 100644 index 0000000..ad580f0 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/SearchController.java @@ -0,0 +1,30 @@ +package com.sundol.controller; + +import com.sundol.service.SearchService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/search") +public class SearchController { + + private final SearchService searchService; + + public SearchController(SearchService searchService) { + this.searchService = searchService; + } + + @GetMapping + public Mono>>> search( + @AuthenticationPrincipal String userId, + @RequestParam String q, + @RequestParam(defaultValue = "5") int topK) { + return searchService.search(userId, q, topK) + .map(ResponseEntity::ok); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/StudyCardController.java b/sundol-backend/src/main/java/com/sundol/controller/StudyCardController.java new file mode 100644 index 0000000..49fb7ae --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/StudyCardController.java @@ -0,0 +1,54 @@ +package com.sundol.controller; + +import com.sundol.dto.StudyCardReviewRequest; +import com.sundol.service.StudyCardService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/study-cards") +public class StudyCardController { + + private final StudyCardService studyCardService; + + public StudyCardController(StudyCardService studyCardService) { + this.studyCardService = studyCardService; + } + + @GetMapping("/due") + public Mono>>> getDueCards( + @AuthenticationPrincipal String userId) { + return studyCardService.getDueCards(userId) + .map(ResponseEntity::ok); + } + + @GetMapping + public Mono>>> getByKnowledgeItem( + @AuthenticationPrincipal String userId, + @RequestParam String knowledgeItemId) { + return studyCardService.getByKnowledgeItem(userId, knowledgeItemId) + .map(ResponseEntity::ok); + } + + @PostMapping("/generate/{knowledgeItemId}") + public Mono>> generate( + @AuthenticationPrincipal String userId, + @PathVariable String knowledgeItemId) { + return studyCardService.generate(userId, knowledgeItemId) + .map(ResponseEntity::ok); + } + + @PostMapping("/{id}/review") + public Mono>> review( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody StudyCardReviewRequest request) { + return studyCardService.review(userId, id, request.rating()) + .map(ResponseEntity::ok); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/TagController.java b/sundol-backend/src/main/java/com/sundol/controller/TagController.java new file mode 100644 index 0000000..4b9b643 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/TagController.java @@ -0,0 +1,54 @@ +package com.sundol.controller; + +import com.sundol.dto.TagRequest; +import com.sundol.service.TagService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/tags") +public class TagController { + + private final TagService tagService; + + public TagController(TagService tagService) { + this.tagService = tagService; + } + + @GetMapping + public Mono>>> list( + @AuthenticationPrincipal String userId) { + return tagService.list(userId) + .map(ResponseEntity::ok); + } + + @PostMapping + public Mono>> create( + @AuthenticationPrincipal String userId, + @RequestBody TagRequest request) { + return tagService.create(userId, request) + .map(ResponseEntity::ok); + } + + @PatchMapping("/{id}") + public Mono>> update( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody TagRequest request) { + return tagService.update(userId, id, request) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> delete( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return tagService.delete(userId, id) + .then(Mono.just(ResponseEntity.ok().build())); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/controller/TodoController.java b/sundol-backend/src/main/java/com/sundol/controller/TodoController.java new file mode 100644 index 0000000..02afd35 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/TodoController.java @@ -0,0 +1,65 @@ +package com.sundol.controller; + +import com.sundol.dto.TodoRequest; +import com.sundol.service.TodoService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/todos") +public class TodoController { + + private final TodoService todoService; + + public TodoController(TodoService todoService) { + this.todoService = todoService; + } + + @GetMapping + public Mono>>> list( + @AuthenticationPrincipal String userId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String priority, + @RequestParam(required = false) String dueDate) { + return todoService.list(userId, status, priority, dueDate) + .map(ResponseEntity::ok); + } + + @PostMapping + public Mono>> create( + @AuthenticationPrincipal String userId, + @RequestBody TodoRequest request) { + return todoService.create(userId, request) + .map(ResponseEntity::ok); + } + + @PatchMapping("/{id}") + public Mono>> update( + @AuthenticationPrincipal String userId, + @PathVariable String id, + @RequestBody Map updates) { + return todoService.update(userId, id, updates) + .map(ResponseEntity::ok); + } + + @DeleteMapping("/{id}") + public Mono> delete( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return todoService.delete(userId, id) + .then(Mono.just(ResponseEntity.ok().build())); + } + + @GetMapping("/{id}/subtasks") + public Mono>>> getSubtasks( + @AuthenticationPrincipal String userId, + @PathVariable String id) { + return todoService.getSubtasks(userId, id) + .map(ResponseEntity::ok); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/dto/ChatMessageRequest.java b/sundol-backend/src/main/java/com/sundol/dto/ChatMessageRequest.java new file mode 100644 index 0000000..67e810a --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/ChatMessageRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record ChatMessageRequest(String content) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/HabitRequest.java b/sundol-backend/src/main/java/com/sundol/dto/HabitRequest.java new file mode 100644 index 0000000..f10be02 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/HabitRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record HabitRequest(String name, String description, String habitType, String targetDays, String color) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java b/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java new file mode 100644 index 0000000..bd56045 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record IngestRequest(String type, String url, String title, String rawText) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/LoginRequest.java b/sundol-backend/src/main/java/com/sundol/dto/LoginRequest.java new file mode 100644 index 0000000..33b36ae --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/LoginRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record LoginRequest(String idToken) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/LoginResponse.java b/sundol-backend/src/main/java/com/sundol/dto/LoginResponse.java new file mode 100644 index 0000000..d611aba --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/LoginResponse.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record LoginResponse(String accessToken, String refreshToken) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/RefreshRequest.java b/sundol-backend/src/main/java/com/sundol/dto/RefreshRequest.java new file mode 100644 index 0000000..62f74b4 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/RefreshRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record RefreshRequest(String refreshToken) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/StudyCardReviewRequest.java b/sundol-backend/src/main/java/com/sundol/dto/StudyCardReviewRequest.java new file mode 100644 index 0000000..4f94e91 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/StudyCardReviewRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record StudyCardReviewRequest(int rating) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/TagRequest.java b/sundol-backend/src/main/java/com/sundol/dto/TagRequest.java new file mode 100644 index 0000000..a60b875 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/TagRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record TagRequest(String name, String color) {} diff --git a/sundol-backend/src/main/java/com/sundol/dto/TodoRequest.java b/sundol-backend/src/main/java/com/sundol/dto/TodoRequest.java new file mode 100644 index 0000000..a5f8dce --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/dto/TodoRequest.java @@ -0,0 +1,3 @@ +package com.sundol.dto; + +public record TodoRequest(String title, String description, String priority, String dueDate, String parentId) {} diff --git a/sundol-backend/src/main/java/com/sundol/exception/AppException.java b/sundol-backend/src/main/java/com/sundol/exception/AppException.java new file mode 100644 index 0000000..8fceebc --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/exception/AppException.java @@ -0,0 +1,16 @@ +package com.sundol.exception; + +import org.springframework.http.HttpStatus; + +public class AppException extends RuntimeException { + private final HttpStatus status; + + public AppException(HttpStatus status, String message) { + super(message); + this.status = status; + } + + public HttpStatus getStatus() { + return status; + } +} diff --git a/sundol-backend/src/main/java/com/sundol/exception/GlobalExceptionHandler.java b/sundol-backend/src/main/java/com/sundol/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..5f99f34 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.sundol.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +@Component +@Order(-2) +public class GlobalExceptionHandler implements ErrorWebExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + HttpStatus status; + String message; + + if (ex instanceof AppException appEx) { + status = appEx.getStatus(); + message = appEx.getMessage(); + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = "Internal server error"; + log.error("Unhandled exception", ex); + } + + exchange.getResponse().setStatusCode(status); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + + String body = "{\"error\":\"" + message.replace("\"", "\\\"") + "\"}"; + DataBuffer buffer = exchange.getResponse().bufferFactory() + .wrap(body.getBytes(StandardCharsets.UTF_8)); + + return exchange.getResponse().writeWith(Mono.just(buffer)); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/ChatRepository.java b/sundol-backend/src/main/java/com/sundol/repository/ChatRepository.java new file mode 100644 index 0000000..fb6d67e --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/ChatRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class ChatRepository { + + private final JdbcTemplate jdbcTemplate; + + public ChatRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for chat_sessions, chat_messages +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/HabitRepository.java b/sundol-backend/src/main/java/com/sundol/repository/HabitRepository.java new file mode 100644 index 0000000..e41e2f8 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/HabitRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class HabitRepository { + + private final JdbcTemplate jdbcTemplate; + + public HabitRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for habits, habit_logs +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeChunkRepository.java b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeChunkRepository.java new file mode 100644 index 0000000..bca967b --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeChunkRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class KnowledgeChunkRepository { + + private final JdbcTemplate jdbcTemplate; + + public KnowledgeChunkRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for knowledge_chunks, VECTOR_DISTANCE search +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java new file mode 100644 index 0000000..d720c8b --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class KnowledgeRepository { + + private final JdbcTemplate jdbcTemplate; + + public KnowledgeRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for knowledge_items table +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/StudyCardRepository.java b/sundol-backend/src/main/java/com/sundol/repository/StudyCardRepository.java new file mode 100644 index 0000000..40fbae2 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/StudyCardRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class StudyCardRepository { + + private final JdbcTemplate jdbcTemplate; + + public StudyCardRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for study_cards, SM-2 queries +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/TagRepository.java b/sundol-backend/src/main/java/com/sundol/repository/TagRepository.java new file mode 100644 index 0000000..98cd387 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/TagRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class TagRepository { + + private final JdbcTemplate jdbcTemplate; + + public TagRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for tags, knowledge_item_tags +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/TodoRepository.java b/sundol-backend/src/main/java/com/sundol/repository/TodoRepository.java new file mode 100644 index 0000000..cac7009 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/TodoRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class TodoRepository { + + private final JdbcTemplate jdbcTemplate; + + public TodoRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: CRUD for todos table +} diff --git a/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java b/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java new file mode 100644 index 0000000..d944135 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java @@ -0,0 +1,16 @@ +package com.sundol.repository; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class UserRepository { + + private final JdbcTemplate jdbcTemplate; + + public UserRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + // TODO: findByGoogleSub, upsert, updateRefreshToken +} diff --git a/sundol-backend/src/main/java/com/sundol/security/JwtAuthenticationFilter.java b/sundol-backend/src/main/java/com/sundol/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..971bd36 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/security/JwtAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.sundol.security; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import io.jsonwebtoken.Claims; +import java.util.List; + +@Component +public class JwtAuthenticationFilter implements WebFilter { + + private final JwtProvider jwtProvider; + + public JwtAuthenticationFilter(JwtProvider jwtProvider) { + this.jwtProvider = jwtProvider; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String token = extractToken(exchange.getRequest()); + + if (token != null && jwtProvider.validateToken(token)) { + Claims claims = jwtProvider.parseToken(token); + String type = claims.get("type", String.class); + + if ("ACCESS".equals(type)) { + var auth = new UsernamePasswordAuthenticationToken( + claims.getSubject(), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return chain.filter(exchange) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(auth)); + } + } + + return chain.filter(exchange); + } + + private String extractToken(ServerHttpRequest request) { + String bearer = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (bearer != null && bearer.startsWith("Bearer ")) { + return bearer.substring(7); + } + return null; + } +} diff --git a/sundol-backend/src/main/java/com/sundol/security/JwtProvider.java b/sundol-backend/src/main/java/com/sundol/security/JwtProvider.java new file mode 100644 index 0000000..4b898da --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/security/JwtProvider.java @@ -0,0 +1,64 @@ +package com.sundol.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtProvider { + + private final SecretKey key; + private final long accessTokenExpiry; + private final long refreshTokenExpiry; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiry}") long accessTokenExpiry, + @Value("${jwt.refresh-token-expiry}") long refreshTokenExpiry) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiry = accessTokenExpiry; + this.refreshTokenExpiry = refreshTokenExpiry; + } + + public String createAccessToken(String userId, String email) { + return createToken(userId, email, "ACCESS", accessTokenExpiry); + } + + public String createRefreshToken(String userId, String email) { + return createToken(userId, email, "REFRESH", refreshTokenExpiry); + } + + private String createToken(String userId, String email, String type, long expiry) { + Date now = new Date(); + return Jwts.builder() + .subject(userId) + .claims(Map.of("email", email, "type", type)) + .issuedAt(now) + .expiration(new Date(now.getTime() + expiry)) + .signWith(key) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/AuthService.java b/sundol-backend/src/main/java/com/sundol/service/AuthService.java new file mode 100644 index 0000000..f56e506 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/AuthService.java @@ -0,0 +1,34 @@ +package com.sundol.service; + +import com.sundol.dto.LoginResponse; +import com.sundol.repository.UserRepository; +import com.sundol.security.JwtProvider; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + public AuthService(UserRepository userRepository, JwtProvider jwtProvider) { + this.userRepository = userRepository; + this.jwtProvider = jwtProvider; + } + + public Mono googleLogin(String idToken) { + // TODO: Verify Google ID token, upsert user, issue JWT pair + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono refresh(String refreshToken) { + // TODO: Validate refresh token, issue new token pair + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono logout(String userId) { + // TODO: Invalidate refresh token + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/ChatService.java b/sundol-backend/src/main/java/com/sundol/service/ChatService.java new file mode 100644 index 0000000..173838e --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/ChatService.java @@ -0,0 +1,43 @@ +package com.sundol.service; + +import com.sundol.repository.ChatRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class ChatService { + + private final ChatRepository chatRepository; + + public ChatService(ChatRepository chatRepository) { + this.chatRepository = chatRepository; + } + + public Mono>> listSessions(String userId) { + // TODO: List chat sessions for user + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> createSession(String userId) { + // TODO: Create new chat session + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono>> getMessages(String userId, String sessionId) { + // TODO: Get messages for chat session + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> 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")); + } + + public Mono deleteSession(String userId, String sessionId) { + // TODO: Delete chat session and messages + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/HabitService.java b/sundol-backend/src/main/java/com/sundol/service/HabitService.java new file mode 100644 index 0000000..24fa229 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/HabitService.java @@ -0,0 +1,49 @@ +package com.sundol.service; + +import com.sundol.dto.HabitRequest; +import com.sundol.repository.HabitRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class HabitService { + + private final HabitRepository habitRepository; + + public HabitService(HabitRepository habitRepository) { + this.habitRepository = habitRepository; + } + + public Mono>> list(String userId) { + // TODO: List habits for user + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> create(String userId, HabitRequest request) { + // TODO: Create habit + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> update(String userId, String id, Map updates) { + // TODO: Update habit + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono delete(String userId, String id) { + // TODO: Delete habit and logs + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> checkin(String userId, String id, String note) { + // TODO: Check in for today + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono>> getLogs(String userId, String id, String from, String to) { + // TODO: Get habit logs + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java b/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java new file mode 100644 index 0000000..6c8f4de --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java @@ -0,0 +1,49 @@ +package com.sundol.service; + +import com.sundol.dto.IngestRequest; +import com.sundol.repository.KnowledgeRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class KnowledgeService { + + private final KnowledgeRepository knowledgeRepository; + + public KnowledgeService(KnowledgeRepository knowledgeRepository) { + this.knowledgeRepository = knowledgeRepository; + } + + public Mono>> 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")); + } + + public Mono> ingest(String userId, IngestRequest request) { + // TODO: Create knowledge item, trigger async ingest pipeline + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> getById(String userId, String id) { + // TODO: Get knowledge item by ID + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> update(String userId, String id, Map updates) { + // TODO: Update knowledge item fields + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono delete(String userId, String id) { + // TODO: Delete knowledge item and all chunks + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono>> getChunks(String userId, String id) { + // TODO: List chunks for knowledge item + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/SearchService.java b/sundol-backend/src/main/java/com/sundol/service/SearchService.java new file mode 100644 index 0000000..86255ed --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/SearchService.java @@ -0,0 +1,23 @@ +package com.sundol.service; + +import com.sundol.repository.KnowledgeChunkRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class SearchService { + + private final KnowledgeChunkRepository chunkRepository; + + public SearchService(KnowledgeChunkRepository chunkRepository) { + this.chunkRepository = chunkRepository; + } + + public Mono>> search(String userId, String query, int topK) { + // TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/StudyCardService.java b/sundol-backend/src/main/java/com/sundol/service/StudyCardService.java new file mode 100644 index 0000000..4294a87 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/StudyCardService.java @@ -0,0 +1,38 @@ +package com.sundol.service; + +import com.sundol.repository.StudyCardRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class StudyCardService { + + private final StudyCardRepository studyCardRepository; + + public StudyCardService(StudyCardRepository studyCardRepository) { + this.studyCardRepository = studyCardRepository; + } + + public Mono>> getDueCards(String userId) { + // TODO: Get cards due for review today + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono>> getByKnowledgeItem(String userId, String knowledgeItemId) { + // TODO: Get cards for a specific knowledge item + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> generate(String userId, String knowledgeItemId) { + // TODO: Trigger AI card generation from knowledge item + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> review(String userId, String id, int rating) { + // TODO: Apply SM-2 algorithm and update card + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/TagService.java b/sundol-backend/src/main/java/com/sundol/service/TagService.java new file mode 100644 index 0000000..6002436 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/TagService.java @@ -0,0 +1,39 @@ +package com.sundol.service; + +import com.sundol.dto.TagRequest; +import com.sundol.repository.TagRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class TagService { + + private final TagRepository tagRepository; + + public TagService(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + public Mono>> list(String userId) { + // TODO: List tags for user + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> create(String userId, TagRequest request) { + // TODO: Create tag + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> update(String userId, String id, TagRequest request) { + // TODO: Update tag + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono delete(String userId, String id) { + // TODO: Delete tag and remove from items + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/service/TodoService.java b/sundol-backend/src/main/java/com/sundol/service/TodoService.java new file mode 100644 index 0000000..46e7382 --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/service/TodoService.java @@ -0,0 +1,44 @@ +package com.sundol.service; + +import com.sundol.dto.TodoRequest; +import com.sundol.repository.TodoRepository; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +@Service +public class TodoService { + + private final TodoRepository todoRepository; + + public TodoService(TodoRepository todoRepository) { + this.todoRepository = todoRepository; + } + + public Mono>> list(String userId, String status, String priority, String dueDate) { + // TODO: List todos with filters + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> create(String userId, TodoRequest request) { + // TODO: Create todo + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono> update(String userId, String id, Map updates) { + // TODO: Update todo fields + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono delete(String userId, String id) { + // TODO: Delete todo and subtasks + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } + + public Mono>> getSubtasks(String userId, String id) { + // TODO: List subtasks for todo + return Mono.error(new UnsupportedOperationException("Not implemented yet")); + } +} diff --git a/sundol-backend/src/main/resources/application.yml b/sundol-backend/src/main/resources/application.yml new file mode 100644 index 0000000..47e9be5 --- /dev/null +++ b/sundol-backend/src/main/resources/application.yml @@ -0,0 +1,24 @@ +server: + port: ${SERVER_PORT:8080} + +oracle: + wallet-path: ${ORACLE_WALLET_PATH} + tns-name: ${ORACLE_TNS_NAME} + username: ${ORACLE_USERNAME} + password: ${ORACLE_PASSWORD} + +jwt: + secret: ${JWT_SECRET} + access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000} + refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000} + +cors: + origin: ${CORS_ORIGIN:http://localhost:3000} + +oci: + compartment-id: ${OCI_COMPARTMENT_ID:} + region: ${OCI_REGION:ap-seoul-1} + +logging: + level: + com.sundol: DEBUG diff --git a/sundol-frontend/eslint.config.mjs b/sundol-frontend/eslint.config.mjs new file mode 100644 index 0000000..bbd17c7 --- /dev/null +++ b/sundol-frontend/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")]; + +export default eslintConfig; diff --git a/sundol-frontend/next.config.ts b/sundol-frontend/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/sundol-frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/sundol-frontend/package.json b/sundol-frontend/package.json new file mode 100644 index 0000000..1f350a6 --- /dev/null +++ b/sundol-frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "sundol-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^15.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "axios": "^1.7.9", + "zustand": "^5.0.3", + "react-markdown": "^9.0.3", + "lucide-react": "^0.469.0" + }, + "devDependencies": { + "typescript": "^5.7.3", + "@types/node": "^22.10.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@tailwindcss/postcss": "^4.1.0", + "tailwindcss": "^4.1.0", + "eslint": "^9.17.0", + "eslint-config-next": "^15.3.1", + "@eslint/eslintrc": "^3" + } +} diff --git a/sundol-frontend/postcss.config.mjs b/sundol-frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/sundol-frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/sundol-frontend/src/app/chat/page.tsx b/sundol-frontend/src/app/chat/page.tsx new file mode 100644 index 0000000..d0b4914 --- /dev/null +++ b/sundol-frontend/src/app/chat/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function ChatPage() { + return ( + + + + AI Chat + + Start a new conversation to ask questions about your knowledge base. + + + + ); +} diff --git a/sundol-frontend/src/app/dashboard/page.tsx b/sundol-frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..2e192ec --- /dev/null +++ b/sundol-frontend/src/app/dashboard/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function DashboardPage() { + return ( + + + + Dashboard + + + + + + + + + + + ); +} + +function DashCard({ title, value, description }: { title: string; value: string; description: string }) { + return ( + + {title} + {value} + {description} + + ); +} diff --git a/sundol-frontend/src/app/globals.css b/sundol-frontend/src/app/globals.css new file mode 100644 index 0000000..64de4f9 --- /dev/null +++ b/sundol-frontend/src/app/globals.css @@ -0,0 +1,21 @@ +@import "tailwindcss"; + +:root { + --color-bg: #0f172a; + --color-bg-card: #1e293b; + --color-bg-hover: #334155; + --color-primary: #3b82f6; + --color-primary-hover: #2563eb; + --color-text: #f1f5f9; + --color-text-muted: #94a3b8; + --color-border: #334155; + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-danger: #ef4444; +} + +body { + background-color: var(--color-bg); + color: var(--color-text); + font-family: system-ui, -apple-system, sans-serif; +} diff --git a/sundol-frontend/src/app/habits/page.tsx b/sundol-frontend/src/app/habits/page.tsx new file mode 100644 index 0000000..36bc5ff --- /dev/null +++ b/sundol-frontend/src/app/habits/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function HabitsPage() { + return ( + + + + + Habits + + + Add Habit + + + + No habits tracked yet. Start building good habits. + + + + ); +} diff --git a/sundol-frontend/src/app/knowledge/page.tsx b/sundol-frontend/src/app/knowledge/page.tsx new file mode 100644 index 0000000..14910de --- /dev/null +++ b/sundol-frontend/src/app/knowledge/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function KnowledgePage() { + return ( + + + + + Knowledge + + + Add Knowledge + + + + No knowledge items yet. Add your first item to get started. + + + + ); +} diff --git a/sundol-frontend/src/app/layout.tsx b/sundol-frontend/src/app/layout.tsx new file mode 100644 index 0000000..9756e41 --- /dev/null +++ b/sundol-frontend/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import { AuthProvider } from "@/lib/auth-context"; + +export const metadata: Metadata = { + title: "SUNDOL", + description: "Smart Unified Natural Dog-Operated Layer", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/sundol-frontend/src/app/login/page.tsx b/sundol-frontend/src/app/login/page.tsx new file mode 100644 index 0000000..e058d12 --- /dev/null +++ b/sundol-frontend/src/app/login/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth-context"; + +export default function LoginPage() { + const router = useRouter(); + const { login } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + + 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); + } + }; + + return ( + + + + SUNDOL + + Smart Unified Natural Dog-Operated Layer + + + + + + + + + + {isLoading ? "Signing in..." : "Sign in with Google"} + + + + ); +} diff --git a/sundol-frontend/src/app/page.tsx b/sundol-frontend/src/app/page.tsx new file mode 100644 index 0000000..8d1a485 --- /dev/null +++ b/sundol-frontend/src/app/page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth-context"; + +export default function Home() { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (!isLoading) { + router.replace(isAuthenticated ? "/dashboard" : "/login"); + } + }, [isAuthenticated, isLoading, router]); + + return ( + + Loading... + + ); +} diff --git a/sundol-frontend/src/app/study/page.tsx b/sundol-frontend/src/app/study/page.tsx new file mode 100644 index 0000000..e1ea8d4 --- /dev/null +++ b/sundol-frontend/src/app/study/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function StudyPage() { + return ( + + + + Study Cards + + No cards due for review. Generate cards from your knowledge items. + + + + ); +} diff --git a/sundol-frontend/src/app/todos/page.tsx b/sundol-frontend/src/app/todos/page.tsx new file mode 100644 index 0000000..b54c8aa --- /dev/null +++ b/sundol-frontend/src/app/todos/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import AuthGuard from "@/components/auth-guard"; +import NavBar from "@/components/nav-bar"; + +export default function TodosPage() { + return ( + + + + + Todos + + + Add Todo + + + + No todos yet. Create your first task. + + + + ); +} diff --git a/sundol-frontend/src/components/auth-guard.tsx b/sundol-frontend/src/components/auth-guard.tsx new file mode 100644 index 0000000..d966839 --- /dev/null +++ b/sundol-frontend/src/components/auth-guard.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth-context"; + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { isAuthenticated, isLoading } = useAuth(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace("/login"); + } + }, [isAuthenticated, isLoading, router]); + + if (isLoading) { + return ( + + Loading... + + ); + } + + if (!isAuthenticated) { + return null; + } + + return <>{children}>; +} diff --git a/sundol-frontend/src/components/nav-bar.tsx b/sundol-frontend/src/components/nav-bar.tsx new file mode 100644 index 0000000..7f4df45 --- /dev/null +++ b/sundol-frontend/src/components/nav-bar.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useAuth } from "@/lib/auth-context"; + +const navItems = [ + { href: "/dashboard", label: "Dashboard" }, + { href: "/knowledge", label: "Knowledge" }, + { href: "/chat", label: "Chat" }, + { href: "/study", label: "Study" }, + { href: "/todos", label: "Todos" }, + { href: "/habits", label: "Habits" }, +]; + +export default function NavBar() { + const pathname = usePathname(); + const { logout } = useAuth(); + + return ( + + + + + + SUNDOL + + + {navItems.map((item) => ( + + {item.label} + + ))} + + + + Logout + + + + + ); +} diff --git a/sundol-frontend/src/lib/api.ts b/sundol-frontend/src/lib/api.ts new file mode 100644 index 0000000..4d8c56f --- /dev/null +++ b/sundol-frontend/src/lib/api.ts @@ -0,0 +1,76 @@ +import axios from "axios"; + +export const api = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080", + withCredentials: true, +}); + +// Types +export interface LoginResponse { + accessToken: string; + refreshToken: string; +} + +export interface KnowledgeItem { + id: string; + userId: string; + type: string; + title: string; + sourceUrl: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface ChatSession { + id: string; + title: string; + createdAt: string; +} + +export interface ChatMessage { + id: string; + role: string; + content: string; + sourceChunks: string; + createdAt: string; +} + +export interface Todo { + id: string; + title: string; + description: string; + status: string; + priority: string; + dueDate: string; + parentId: string; + createdAt: string; +} + +export interface Habit { + id: string; + name: string; + description: string; + habitType: string; + targetDays: string; + color: string; + streakCurrent: number; + streakBest: number; + createdAt: string; +} + +export interface StudyCard { + id: string; + knowledgeItemId: string; + front: string; + back: string; + easeFactor: number; + interval: number; + nextReviewAt: string; +} + +export interface Tag { + id: string; + name: string; + color: string; +} diff --git a/sundol-frontend/src/lib/auth-context.tsx b/sundol-frontend/src/lib/auth-context.tsx new file mode 100644 index 0000000..dbb8bff --- /dev/null +++ b/sundol-frontend/src/lib/auth-context.tsx @@ -0,0 +1,81 @@ +"use client"; + +import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; +import { api, LoginResponse } from "./api"; + +interface AuthContextType { + isAuthenticated: boolean; + isLoading: boolean; + accessToken: string | null; + login: (response: LoginResponse) => void; + logout: () => void; + setAccessToken: (token: string) => void; +} + +const AuthContext = createContext({ + isAuthenticated: false, + isLoading: true, + accessToken: null, + login: () => {}, + logout: () => {}, + setAccessToken: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [accessToken, setAccessToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Try to restore session from refresh token cookie + const tryRefresh = async () => { + try { + const res = await api.post("/api/auth/refresh"); + setAccessToken(res.data.accessToken); + } catch { + // No valid session + } finally { + setIsLoading(false); + } + }; + tryRefresh(); + }, []); + + useEffect(() => { + if (accessToken) { + api.defaults.headers.common["Authorization"] = `Bearer ${accessToken}`; + } else { + delete api.defaults.headers.common["Authorization"]; + } + }, [accessToken]); + + const login = useCallback((response: LoginResponse) => { + setAccessToken(response.accessToken); + }, []); + + const logout = useCallback(async () => { + try { + await api.post("/api/auth/logout"); + } catch { + // Ignore logout errors + } + setAccessToken(null); + window.location.href = "/login"; + }, []); + + return ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/sundol-frontend/src/lib/use-api.ts b/sundol-frontend/src/lib/use-api.ts new file mode 100644 index 0000000..3f32502 --- /dev/null +++ b/sundol-frontend/src/lib/use-api.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useCallback } from "react"; +import { api, LoginResponse } from "./api"; +import { useAuth } from "./auth-context"; +import { AxiosRequestConfig } from "axios"; + +export function useApi() { + const { setAccessToken, logout } = useAuth(); + + const request = useCallback( + async (config: AxiosRequestConfig): Promise => { + try { + const response = await api.request(config); + return response.data; + } catch (error: any) { + if (error.response?.status === 401) { + try { + const refreshRes = await api.post("/api/auth/refresh"); + setAccessToken(refreshRes.data.accessToken); + api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`; + const retryResponse = await api.request(config); + return retryResponse.data; + } catch { + logout(); + throw error; + } + } + throw error; + } + }, + [setAccessToken, logout] + ); + + return { request }; +} diff --git a/sundol-frontend/tsconfig.json b/sundol-frontend/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/sundol-frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}
Start a new conversation to ask questions about your knowledge base.
{value}
{description}
No habits tracked yet. Start building good habits.
No knowledge items yet. Add your first item to get started.
+ Smart Unified Natural Dog-Operated Layer +
No cards due for review. Generate cards from your knowledge items.
No todos yet. Create your first task.