Add backend/frontend scaffolding with Oracle ADB wallet config
- Backend: Spring Boot 3 + WebFlux, JWT auth, Oracle ADB wallet, 8 controllers/services/repositories (Auth~Tag), DTOs, exception handling - Frontend: Next.js 15, TypeScript, Tailwind CSS, AuthContext, 7 pages (dashboard, knowledge, chat, study, todos, habits, login) - DB: V1 migration with 12 tables including VECTOR(1024) + HNSW index - Ops: PM2 ecosystem config, deploy.sh, start-backend.sh - CLAUDE.md: DB credentials replaced with env var references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
.env.sample
26
.env.sample
@@ -5,17 +5,25 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# Database (Oracle 23ai)
|
# Database (Oracle ADB)
|
||||||
# ---------------------
|
# ---------------------
|
||||||
DB_HOST=localhost
|
ORACLE_WALLET_PATH=/path/to/wallet
|
||||||
DB_SERVICE=FREEPDB1
|
ORACLE_TNS_NAME=dbdf0q9672liizzd_medium
|
||||||
DB_USER=sundol
|
ORACLE_USERNAME=admin
|
||||||
DB_PASSWORD=
|
ORACLE_PASSWORD=
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# JWT
|
# JWT
|
||||||
# ---------------------
|
# ---------------------
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRY=900000
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRY=604800000
|
||||||
|
|
||||||
|
# ---------------------
|
||||||
|
# Server
|
||||||
|
# ---------------------
|
||||||
|
SERVER_PORT=8080
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
@@ -27,7 +35,7 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
# OCI GenAI
|
# OCI GenAI
|
||||||
# ---------------------
|
# ---------------------
|
||||||
OCI_COMPARTMENT_ID=
|
OCI_COMPARTMENT_ID=
|
||||||
OCI_REGION=us-chicago-1
|
OCI_REGION=ap-seoul-1
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# YouTube (optional)
|
# YouTube (optional)
|
||||||
@@ -47,9 +55,3 @@ NEXT_PUBLIC_API_URL=http://localhost:8080
|
|||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
NEXTAUTH_SECRET=
|
NEXTAUTH_SECRET=
|
||||||
API_URL=http://localhost:8080
|
API_URL=http://localhost:8080
|
||||||
|
|
||||||
# ---------------------
|
|
||||||
# Gitea
|
|
||||||
# ---------------------
|
|
||||||
GITEA_USER=joungmin
|
|
||||||
GITEA_PASSWORD=
|
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -23,14 +23,15 @@
|
|||||||
|
|
||||||
# DB 접속 (Oracle Autonomous DB - SQLcl)
|
# DB 접속 (Oracle Autonomous DB - SQLcl)
|
||||||
|
|
||||||
- 환경변수 파일: `/Users/joungminko/devkit/account_manager/.env`
|
- 환경변수 파일: `.env` (프로젝트 루트)
|
||||||
- SQLcl 실행:
|
- SQLcl 실행 (DB 작업은 반드시 SQLcl을 통해 수행):
|
||||||
```bash
|
```bash
|
||||||
export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home
|
# .env에서 ORACLE_WALLET_PATH, ORACLE_TNS_NAME, ORACLE_USERNAME, ORACLE_PASSWORD 참조
|
||||||
export TNS_ADMIN=/Users/joungminko/devkit/db_conn/Wallet_WKW7PT1B3PIK6DTI
|
set -a && source .env && set +a
|
||||||
/opt/homebrew/Caskroom/sqlcl/25.4.2.044.1837/sqlcl/bin/sql admin/Dhfkzmf#12345@wkw7pt1b3pik6dti_medium
|
sql ${ORACLE_USERNAME}/${ORACLE_PASSWORD}@${ORACLE_TNS_NAME}?TNS_ADMIN=${ORACLE_WALLET_PATH}
|
||||||
```
|
```
|
||||||
- DDL 변경이 필요하면 SQLcl로 직접 ALTER TABLE 실행할 것
|
- DDL 변경이 필요하면 SQLcl로 직접 ALTER TABLE 실행할 것
|
||||||
|
- 테이블 생성/변경 등 모든 DB 스키마 작업은 SQLcl을 통해 수행
|
||||||
|
|
||||||
# 코드 설계 원칙
|
# 코드 설계 원칙
|
||||||
|
|
||||||
|
|||||||
156
db/migration/V1__create_tables.sql
Normal file
156
db/migration/V1__create_tables.sql
Normal file
@@ -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);
|
||||||
33
deploy.sh
Executable file
33
deploy.sh
Executable file
@@ -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 ==="
|
||||||
23
ecosystem.config.cjs
Normal file
23
ecosystem.config.cjs
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
9
start-backend.sh
Executable file
9
start-backend.sh
Executable file
@@ -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
|
||||||
129
sundol-backend/pom.xml
Normal file
129
sundol-backend/pom.xml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.4.4</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.sundol</groupId>
|
||||||
|
<artifactId>sundol-backend</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>sundol-backend</name>
|
||||||
|
<description>SUNDOL Backend</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot WebFlux (Reactive) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot JDBC -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Oracle JDBC -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.jdbc</groupId>
|
||||||
|
<artifactId>ojdbc11</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.jdbc</groupId>
|
||||||
|
<artifactId>ucp</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.security</groupId>
|
||||||
|
<artifactId>oraclepki</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.security</groupId>
|
||||||
|
<artifactId>osdt_cert</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.oracle.database.security</groupId>
|
||||||
|
<artifactId>osdt_core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.12.6</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jsoup -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.18.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<mainClass>com.sundol.SundolApplication</mainClass>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) {
|
||||||
|
return authService.googleLogin(request.idToken())
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) {
|
||||||
|
return authService.refresh(request.refreshToken())
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public Mono<ResponseEntity<Void>> logout(@RequestAttribute("userId") String userId) {
|
||||||
|
return authService.logout(userId)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> listSessions(
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return chatService.listSessions(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sessions")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> createSession(
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return chatService.createSession(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sessions/{id}/messages")
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getMessages(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return chatService.getMessages(userId, id)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sessions/{id}/messages")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> 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<ResponseEntity<Void>> deleteSession(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return chatService.deleteSession(userId, id)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> list(
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return habitService.list(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> create(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody HabitRequest request) {
|
||||||
|
return habitService.create(userId, request)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> updates) {
|
||||||
|
return habitService.update(userId, id, updates)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Void>> delete(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return habitService.delete(userId, id)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/checkin")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> checkin(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody(required = false) Map<String, String> body) {
|
||||||
|
String note = body != null ? body.get("note") : null;
|
||||||
|
return habitService.checkin(userId, id, note)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/logs")
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> 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<ResponseEntity<Map<String, Object>>> ingest(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody IngestRequest request) {
|
||||||
|
return knowledgeService.ingest(userId, request)
|
||||||
|
.map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> getById(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return knowledgeService.getById(userId, id)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> updates) {
|
||||||
|
return knowledgeService.update(userId, id, updates)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Void>> delete(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return knowledgeService.delete(userId, id)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/chunks")
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return knowledgeService.getChunks(userId, id)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> search(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String q,
|
||||||
|
@RequestParam(defaultValue = "5") int topK) {
|
||||||
|
return searchService.search(userId, q, topK)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> getDueCards(
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return studyCardService.getDueCards(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getByKnowledgeItem(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String knowledgeItemId) {
|
||||||
|
return studyCardService.getByKnowledgeItem(userId, knowledgeItemId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate/{knowledgeItemId}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> generate(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String knowledgeItemId) {
|
||||||
|
return studyCardService.generate(userId, knowledgeItemId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/review")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> review(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody StudyCardReviewRequest request) {
|
||||||
|
return studyCardService.review(userId, id, request.rating())
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> list(
|
||||||
|
@AuthenticationPrincipal String userId) {
|
||||||
|
return tagService.list(userId)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> create(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody TagRequest request) {
|
||||||
|
return tagService.create(userId, request)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody TagRequest request) {
|
||||||
|
return tagService.update(userId, id, request)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Void>> delete(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return tagService.delete(userId, id)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ResponseEntity<List<Map<String, Object>>>> 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<ResponseEntity<Map<String, Object>>> create(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody TodoRequest request) {
|
||||||
|
return todoService.create(userId, request)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> update(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody Map<String, Object> updates) {
|
||||||
|
return todoService.update(userId, id, updates)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Mono<ResponseEntity<Void>> delete(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return todoService.delete(userId, id)
|
||||||
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/subtasks")
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getSubtasks(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id) {
|
||||||
|
return todoService.getSubtasks(userId, id)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record ChatMessageRequest(String content) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record HabitRequest(String name, String description, String habitType, String targetDays, String color) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record IngestRequest(String type, String url, String title, String rawText) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record LoginRequest(String idToken) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record LoginResponse(String accessToken, String refreshToken) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record RefreshRequest(String refreshToken) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record StudyCardReviewRequest(int rating) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record TagRequest(String name, String color) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.sundol.dto;
|
||||||
|
|
||||||
|
public record TodoRequest(String title, String description, String priority, String dueDate, String parentId) {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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> refresh(String refreshToken) {
|
||||||
|
// TODO: Validate refresh token, issue new token pair
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> logout(String userId) {
|
||||||
|
// TODO: Invalidate refresh token
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Map<String, Object>>> listSessions(String userId) {
|
||||||
|
// TODO: List chat sessions for user
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> createSession(String userId) {
|
||||||
|
// TODO: Create new chat session
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> deleteSession(String userId, String sessionId) {
|
||||||
|
// TODO: Delete chat session and messages
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, Object>>> list(String userId) {
|
||||||
|
// TODO: List habits for user
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
|
||||||
|
// TODO: Create habit
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> delete(String userId, String id) {
|
||||||
|
// TODO: Delete habit and logs
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> getById(String userId, String id) {
|
||||||
|
// TODO: Get knowledge item by ID
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> delete(String userId, String id) {
|
||||||
|
// TODO: Delete knowledge item and all chunks
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Map<String, Object>>> getDueCards(String userId) {
|
||||||
|
// TODO: Get cards due for review today
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Map<String, Object>>> list(String userId) {
|
||||||
|
// TODO: List tags for user
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
|
||||||
|
// TODO: Create tag
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
|
||||||
|
// TODO: Update tag
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> delete(String userId, String id) {
|
||||||
|
// TODO: Delete tag and remove from items
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
|
||||||
|
// TODO: Create todo
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> delete(String userId, String id) {
|
||||||
|
// TODO: Delete todo and subtasks
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
|
||||||
|
// TODO: List subtasks for todo
|
||||||
|
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
24
sundol-backend/src/main/resources/application.yml
Normal file
24
sundol-backend/src/main/resources/application.yml
Normal file
@@ -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
|
||||||
14
sundol-frontend/eslint.config.mjs
Normal file
14
sundol-frontend/eslint.config.mjs
Normal file
@@ -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;
|
||||||
7
sundol-frontend/next.config.ts
Normal file
7
sundol-frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
31
sundol-frontend/package.json
Normal file
31
sundol-frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
sundol-frontend/postcss.config.mjs
Normal file
7
sundol-frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
18
sundol-frontend/src/app/chat/page.tsx
Normal file
18
sundol-frontend/src/app/chat/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
sundol-frontend/src/app/dashboard/page.tsx
Normal file
33
sundol-frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<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" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
sundol-frontend/src/app/globals.css
Normal file
21
sundol-frontend/src/app/globals.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
sundol-frontend/src/app/habits/page.tsx
Normal file
23
sundol-frontend/src/app/habits/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function HabitsPage() {
|
||||||
|
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">Habits</h1>
|
||||||
|
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
||||||
|
+ 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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
sundol-frontend/src/app/knowledge/page.tsx
Normal file
23
sundol-frontend/src/app/knowledge/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function KnowledgePage() {
|
||||||
|
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">
|
||||||
|
+ Add Knowledge
|
||||||
|
</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 knowledge items yet. Add your first item to get started.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
sundol-frontend/src/app/layout.tsx
Normal file
22
sundol-frontend/src/app/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<html lang="ko">
|
||||||
|
<body>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
sundol-frontend/src/app/login/page.tsx
Normal file
50
sundol-frontend/src/app/login/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-2xl p-8 w-full max-w-md shadow-xl border border-[var(--color-border)]">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-2">SUNDOL</h1>
|
||||||
|
<p className="text-[var(--color-text-muted)]">
|
||||||
|
Smart Unified Natural Dog-Operated Layer
|
||||||
|
</p>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<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"/>
|
||||||
|
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<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"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
sundol-frontend/src/app/page.tsx
Normal file
22
sundol-frontend/src/app/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-[var(--color-text-muted)]">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
sundol-frontend/src/app/study/page.tsx
Normal file
18
sundol-frontend/src/app/study/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function StudyPage() {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
sundol-frontend/src/app/todos/page.tsx
Normal file
23
sundol-frontend/src/app/todos/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
|
||||||
|
export default function TodosPage() {
|
||||||
|
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">Todos</h1>
|
||||||
|
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
||||||
|
+ 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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
sundol-frontend/src/components/auth-guard.tsx
Normal file
30
sundol-frontend/src/components/auth-guard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-[var(--color-text-muted)]">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
54
sundol-frontend/src/components/nav-bar.tsx
Normal file
54
sundol-frontend/src/components/nav-bar.tsx
Normal file
@@ -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 (
|
||||||
|
<nav className="bg-[var(--color-bg-card)] border-b border-[var(--color-border)]">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<Link href="/dashboard" className="text-xl font-bold">
|
||||||
|
SUNDOL
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
pathname === item.href
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-white hover:bg-[var(--color-bg-hover)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-sm text-[var(--color-text-muted)] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
sundol-frontend/src/lib/api.ts
Normal file
76
sundol-frontend/src/lib/api.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
81
sundol-frontend/src/lib/auth-context.tsx
Normal file
81
sundol-frontend/src/lib/auth-context.tsx
Normal file
@@ -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<AuthContextType>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
accessToken: null,
|
||||||
|
login: () => {},
|
||||||
|
logout: () => {},
|
||||||
|
setAccessToken: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Try to restore session from refresh token cookie
|
||||||
|
const tryRefresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.post<LoginResponse>("/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 (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
isAuthenticated: !!accessToken,
|
||||||
|
isLoading,
|
||||||
|
accessToken,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setAccessToken,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
36
sundol-frontend/src/lib/use-api.ts
Normal file
36
sundol-frontend/src/lib/use-api.ts
Normal file
@@ -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 <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||||
|
try {
|
||||||
|
const response = await api.request<T>(config);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
|
||||||
|
setAccessToken(refreshRes.data.accessToken);
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`;
|
||||||
|
const retryResponse = await api.request<T>(config);
|
||||||
|
return retryResponse.data;
|
||||||
|
} catch {
|
||||||
|
logout();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setAccessToken, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
27
sundol-frontend/tsconfig.json
Normal file
27
sundol-frontend/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user