From 0ec40150bb48a56728ae486730efe256c6f69cb9 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 14:39:22 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20codex=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/hooks/notify.ps1 | 9 + .codex/hooks/notify.py | 29 +++ .codex/hooks/post-edit-check.py | 48 ++++ .codex/settings.json | 29 +++ .codex/settings.local.json | 30 +++ .codex/skills/review-pr/SKILL.md | 292 +++++++++++++++++++++++ .codex/skills/test/SKILL.md | 248 ++++++++++++++++++++ AGENTS.md | 388 +++++++++++++++++++++++++++++++ 8 files changed, 1073 insertions(+) create mode 100644 .codex/hooks/notify.ps1 create mode 100644 .codex/hooks/notify.py create mode 100644 .codex/hooks/post-edit-check.py create mode 100644 .codex/settings.json create mode 100644 .codex/settings.local.json create mode 100644 .codex/skills/review-pr/SKILL.md create mode 100644 .codex/skills/test/SKILL.md create mode 100644 AGENTS.md diff --git a/.codex/hooks/notify.ps1 b/.codex/hooks/notify.ps1 new file mode 100644 index 000000000..20b49a249 --- /dev/null +++ b/.codex/hooks/notify.ps1 @@ -0,0 +1,9 @@ +Add-Type -AssemblyName System.Windows.Forms +$n = New-Object System.Windows.Forms.NotifyIcon +$n.Icon = [System.Drawing.SystemIcons]::Information +$n.Visible = $true +$n.BalloonTipTitle = "Claude Code" +$n.BalloonTipText = "Awaiting your input" +$n.ShowBalloonTip(5000) +Start-Sleep -Milliseconds 5100 +$n.Dispose() diff --git a/.codex/hooks/notify.py b/.codex/hooks/notify.py new file mode 100644 index 000000000..6a2828d41 --- /dev/null +++ b/.codex/hooks/notify.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import os +import platform +import subprocess + +system = platform.system() +script_dir = os.path.dirname(os.path.abspath(__file__)) + +if system == "Darwin": + subprocess.run([ + "osascript", "-e", + 'display notification "Awaiting your input" with title "Claude Code"' + ]) +elif system == "Windows": + ps1_path = os.path.join(script_dir, "notify.ps1") + # VS Code extension 환경에서는 PATH에 powershell이 없을 수 있으므로 절대 경로 사용 + powershell_candidates = [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "powershell", + ] + for ps in powershell_candidates: + try: + subprocess.run( + [ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1_path], + timeout=10, + ) + break + except (FileNotFoundError, subprocess.TimeoutExpired): + continue diff --git a/.codex/hooks/post-edit-check.py b/.codex/hooks/post-edit-check.py new file mode 100644 index 000000000..0b5c1b14c --- /dev/null +++ b/.codex/hooks/post-edit-check.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import json +import sys +import re + +data = json.load(sys.stdin) +file_path = data.get("tool_input", {}).get("file_path", "") + +if not file_path.endswith(".java") or not file_path: + sys.exit(0) + +try: + with open(file_path) as f: + content = f.read() + lines = content.split("\n") +except Exception: + sys.exit(0) + +warnings = [] + +# 1. 와일드카드 import 체크 +for i, line in enumerate(lines, 1): + if re.match(r"\s*import\s+.*\.\*;", line): + warnings.append(f"L{i}: 와일드카드 import 발견 -> 명시적 import 필요") + +# 2. 파일 끝 줄바꿈 체크 +if content and not content.endswith("\n"): + warnings.append("파일 끝 줄바꿈 누락") + +# 3. Entity 클래스의 @Column 체크 +if "@Entity" in content: + field_pattern = re.compile(r"^\s+private\s+\w+(?:<[^>]+>)?\s+\w+;") + relation_annotations = { + "@Column", "@Id", "@ManyToOne", "@OneToMany", + "@JoinColumn", "@OneToOne", "@ManyToMany", + "@Transient", "@Version", "@Embedded", "@EmbeddedId", + } + for i, line in enumerate(lines): + if field_pattern.match(line): + preceding = "\n".join(lines[max(0, i - 5):i]) + has_annotation = any(ann in preceding for ann in relation_annotations) + if not has_annotation: + warnings.append(f"L{i + 1}: Entity 필드에 @Column 누락 가능성: {line.strip()}") + +if warnings: + print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]") + for w in warnings: + print(f" - {w}") diff --git a/.codex/settings.json b/.codex/settings.json new file mode 100644 index 000000000..f6c5b8ec9 --- /dev/null +++ b/.codex/settings.json @@ -0,0 +1,29 @@ +{ + "env": { + "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" + }, + "hooks": { + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/notify.py 2>/dev/null || python .claude/hooks/notify.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/post-edit-check.py 2>/dev/null || python .claude/hooks/post-edit-check.py" + } + ] + } + ] + } +} diff --git a/.codex/settings.local.json b/.codex/settings.local.json new file mode 100644 index 000000000..a10579d39 --- /dev/null +++ b/.codex/settings.local.json @@ -0,0 +1,30 @@ +{ + "permissions": { + "allow": [ + "mcp__serena__list_dir", + "mcp__serena__read_file", + "mcp__serena__find_file", + "mcp__serena__get_symbols_overview", + "mcp__serena__find_symbol", + "mcp__serena__think_about_collected_information", + "mcp__serena__search_for_pattern", + "WebFetch(domain:javadoc.io)", + "WebFetch(domain:www.baeldung.com)", + "WebFetch(domain:michael-simons.github.io)", + "WebFetch(domain:raw.githubusercontent.com)", + "mcp__serena__replace_content", + "mcp__serena__create_text_file", + "Bash(./gradlew test:*)", + "Bash(./gradlew compileTestJava:*)", + "Bash(./gradlew clean test:*)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr checks:*)", + "Bash(gh run view:*)", + "Bash(gh pr commits:*)", + "Bash(gh api:*)", + "Bash(git fetch:*)", + "Bash(cd:*)" + ] + } +} diff --git a/.codex/skills/review-pr/SKILL.md b/.codex/skills/review-pr/SKILL.md new file mode 100644 index 000000000..8a50da5ed --- /dev/null +++ b/.codex/skills/review-pr/SKILL.md @@ -0,0 +1,292 @@ +--- +name: review-pr +description: Pull Request를 체계적으로 리뷰하여 프로젝트 컨벤션 준수 여부와 코드 품질을 검증합니다 +args: (예: /review-pr 666) +--- + +# Pull Request 리뷰 가이드 + +이 skill은 solid-connect-server 프로젝트의 Pull Request를 체계적으로 리뷰합니다. + +## 사용법 + +```bash +/review-pr +``` + +**예제:** + +```bash +/review-pr 666 +``` + +--- + +## 리뷰 프로세스 + +### 1단계: PR 정보 수집 + +GitHub CLI로 PR의 기본 정보와 변경사항을 파악합니다. + +```bash +gh pr view <번호> -R solid-connection/solid-connect-server # PR 기본 정보 조회 +gh pr diff <번호> -R solid-connection/solid-connect-server # 변경된 파일과 diff 확인 +gh pr checks <번호> -R solid-connection/solid-connect-server # CI/CD 상태 확인 +``` + +**수집할 정보:** + +- PR 제목 및 설명 +- 관련 이슈 번호 +- 변경된 파일 목록 +- CI/CD 체크 상태 + +### 2단계: 변경 파일 분석 + +**도구 우선순위:** + +1. **Serena MCP** (Java 코드 분석에 최적화) + - `mcp__serena__get_symbols_overview <파일경로>` - 파일의 클래스/메서드 구조 파악 + - `mcp__serena__find_symbol <심볼명>` - 특정 심볼 검색 + - `mcp__serena__search_for_pattern <패턴>` - 컨벤션 위반 패턴 검색 + +2. **Read/Grep** (보조 분석) + - `Read <파일경로>` - 파일 전체 읽기 + - `Grep --pattern <패턴>` - 패턴 검색 + +### 3단계: 체크리스트 검증 + +아래 체크리스트를 순서대로 확인합니다. + +--- + +## 리뷰 체크리스트 + +각 항목의 상세 컨벤션은 참조 문서를 확인하세요. + +### 1. 아키텍처 및 계층 구조 + +**검증 항목:** + +- 계층형 아키텍처 준수 (Controller → Service → Repository) +- 역계층 참조 금지 +- 순환 의존성 없음 + +👉 **참고:** `CLAUDE.md` - "아키텍처" 섹션 + +--- + +### 2. 네이밍 컨벤션 + +**검증 항목:** + +- API 엔드포인트: kebab-case 사용 (예: `/user-profile`) +- DTO 변환 메서드: 단일 파라미터 `from()`, 다중 파라미터 `of()` +- Request/Response: `XXXRequest`, `XXXResponse` 형식 +- 테스트 메서드: 한국어, `어떤_것을_하면_어떤_결과가_나온다()` 패턴 + +👉 **참고:** `CLAUDE.md` - "네이밍 컨벤션" 섹션 + +--- + +### 3. 코드 스타일 + +**검증 항목:** + +- 와일드카드(`*`) import 금지 +- 클래스 선언 전 빈 줄 존재 +- private 메서드는 호출하는 public 메서드 바로 아래 위치 +- Controller: 모든 파라미터 줄바꿈 필수 +- 일반 메서드: 3개 이상 파라미터 시 줄바꿈 +- 파일 끝 개행 문자 + +**패턴 검색 예제:** + +```bash +mcp__serena__search_for_pattern "import.*\\*" # 와일드카드 import 검색 +``` + +👉 **참고:** `CLAUDE.md` - "코드 스타일" 섹션 + +--- + +### 4. Entity 및 JPA + +**검증 항목:** + +- 모든 필드에 `@Column` 어노테이션 존재 +- `name` 속성으로 컬럼명 명시 +- `nullable` 속성 명시 +- null 불가: 원시 타입 (`int`, `long`, `boolean`) +- nullable: Wrapper 타입 (`Integer`, `Long`, `Boolean`) +- 양방향 연관관계 시 편의 메서드 존재 + +👉 **참고:** `CLAUDE.md` - "데이터베이스 접근" 섹션 + +--- + +### 5. 데이터베이스 마이그레이션 + +**검증 항목:** + +- 스키마 변경 시 Flyway 마이그레이션 파일 추가 +- 파일명 형식: `V{VERSION}__{DESCRIPTION}.sql` +- 위치: `src/main/resources/db/migration/` +- Entity 변경과 마이그레이션 일치 +- 기존 마이그레이션 파일 수정 금지 (새 버전 생성) + +👉 **참고:** `CLAUDE.md` - "데이터베이스 마이그레이션" 섹션 + +--- + +### 6. 테스트 코드 + +**검증 항목:** + +- 새로운 Service/Repository 메서드에 대한 테스트 존재 +- 예외 케이스 테스트 포함 +- `@TestContainerSpringBootTest` 어노테이션 사용 +- `@DisplayName`으로 한글 설명 제공 +- `@Nested`로 기능별 그룹화 +- Given-When-Then 구조 준수 +- Fixture 패턴 사용 (FixtureBuilder + Fixture) + +👉 **참고:** `.claude/skills/test/SKILL.md` + +--- + +### 7. 커밋 메시지 + +**검증 항목:** + +- `: ` 형식 +- Type: `feat`, `fix`, `refactor`, `test`, `chore`, `docs`, `perf` +- 간결하고 명확한 설명 + +👉 **참고:** `CLAUDE.md` - "Git 커밋 컨벤션" 섹션 + +--- + +### 8. 코드 품질 및 잠재적 이슈 + +**검증 항목:** + +- 비즈니스 로직은 Service 계층에만 +- Controller는 요청/응답 처리만 +- `@Transactional` 적절하게 사용 (읽기 전용: `readOnly = true`) +- CustomException 사용 +- N+1 쿼리 문제 없음 +- 인증/인가 처리 (`@AuthorizedUser`) +- 민감 정보 노출 없음 + +👉 **참고:** `CLAUDE.md` - "아키텍처", "기술 스택 상세" 섹션 + +--- + +## 도구 사용 가이드 + +### Serena MCP (우선 사용) + +```bash +# 파일의 클래스/메서드 구조 파악 +mcp__serena__get_symbols_overview src/main/java/.../MentorService.java + +# 특정 심볼 검색 +mcp__serena__find_symbol "MentorDetailResponse" + +# 컨벤션 위반 패턴 검색 +mcp__serena__search_for_pattern "import.*\\*" +``` + +### GitHub CLI + +```bash +# PR 정보 +gh pr view 666 -R solid-connection/solid-connect-server --json title,body,author,number,url + +# 변경사항 +gh pr diff 666 -R solid-connection/solid-connect-server --patch + +# CI 상태 +gh pr checks 666 -R solid-connection/solid-connect-server +``` + +### 보조 도구 + +```bash +# 파일 읽기 +Read src/main/java/.../MentorService.java + +# 패턴 검색 +Grep --pattern "@Column" --glob "*.java" --path src/main/java/.../domain +``` + +--- + +## 리뷰 결과 출력 형식 + +다음 형식으로 리뷰 결과를 정리하여 제공합니다. + +```markdown +## PR 리뷰 결과: #{번호} - {제목} + +**PR 링크:** {GitHub URL} +**관련 이슈:** #{이슈번호} + +### 📊 PR 정보 요약 + +- **작성자:** {작성자} +- **변경 파일:** {숫자}개 +- **추가 라인:** +{숫자}, **삭제 라인:** -{숫자} +- **CI/CD 상태:** {통과/실패} + +### 주요 변경사항 + +{PR 설명 요약} + +--- + +### ✅ 통과 항목 + +- 아키텍처 계층 구조 준수 +- 네이밍 컨벤션 준수 +- ... + +### ⚠️ 개선 권장 항목 + +- **코드 스타일**: 와일드카드 import 사용 + - 파일: `src/main/java/.../MentorService.java:5` + - 개선: 명시적 import로 변경 + +### ❌ 필수 수정 항목 + +- **Entity**: @Column 어노테이션 누락 + - 파일: `src/main/java/.../domain/Mentor.java:30` + - 수정 방향: 모든 필드에 `@Column` 어노테이션 추가 + +--- + +### 💡 종합 의견 + +{전반적인 리뷰 의견} + +**승인 상태:** ✅ 승인 / ⚠️ 조건부 승인 / ❌ 수정 후 재검토 +``` + +--- + +## 리뷰 시 주의사항 + +1. **컨텍스트 이해 우선**: PR 설명과 관련 이슈를 먼저 읽고 변경의 목적 파악 +2. **Serena MCP 우선 사용**: Java 코드 분석 시 효율적 +3. **건설적 피드백**: 문제점 지적 시 구체적인 개선 방향 제시 +4. **긍정적 피드백**: 잘된 부분도 언급하여 균형 잡힌 리뷰 +5. **우선순위**: 아키텍처 > 네이밍 > 스타일 순으로 중요도 판단 + +--- + +## 참고 자료 + +- **프로젝트 컨벤션**: `CLAUDE.md` - 전체 개발 컨벤션 +- **테스트 가이드**: `.claude/skills/test/SKILL.md` - 테스트 작성 가이드 +- **개발 컨벤션 위키**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 diff --git a/.codex/skills/test/SKILL.md b/.codex/skills/test/SKILL.md new file mode 100644 index 000000000..7fd7e4c58 --- /dev/null +++ b/.codex/skills/test/SKILL.md @@ -0,0 +1,248 @@ +--- +name: test +description: 테스트 코드를 작성하거나 수정할 때 이 프로젝트의 테스트 컨벤션과 패턴을 참고합니다 +--- + +# 테스트 코드 작성 가이드 + +## 테스트 기본 설정 + +모든 통합 테스트는 `@TestContainerSpringBootTest` 어노테이션을 사용합니다. + +```java +@TestContainerSpringBootTest +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + // 테스트 코드 +} +``` + +**제공 기능:** + +- MySQL, Redis 자동 실행 +- Spring Boot 컨텍스트 로드 +- 테스트 후 자동 DB 초기화 +- JUnit 5 기반 + +## Fixture 패턴 + +테스트 데이터는 Fixture로 생성합니다 (FixtureBuilder + Fixture 패턴). + +**위치:** `src/test/java/com/example/solidconnection/[domain]/fixture/` + +``` +fixture/ +├── [Entity]FixtureBuilder.java # Builder 패턴 구현 +└── [Entity]Fixture.java # 편의 메서드 제공 +``` + +### 예제: ChatRoomFixtureBuilder + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixtureBuilder { + + private final ChatRoomRepository chatRoomRepository; + + private boolean isGroup; + private Long mentoringId; + + public ChatRoomFixtureBuilder chatRoom() { + return new ChatRoomFixtureBuilder(chatRoomRepository); + } + + public ChatRoomFixtureBuilder isGroup(boolean isGroup) { + this.isGroup = isGroup; + return this; + } + + public ChatRoomFixtureBuilder mentoringId(long mentoringId) { + this.mentoringId = mentoringId; + return this; + } + + public ChatRoom create() { + ChatRoom chatRoom = new ChatRoom(mentoringId, isGroup); + return chatRoomRepository.save(chatRoom); // DB 저장 + } +} +``` + +### 예제: ChatRoomFixture + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixture { + + private final ChatRoomFixtureBuilder chatRoomFixtureBuilder; + + // 편의 메서드: 기본값으로 생성 + public ChatRoom 채팅방(boolean isGroup) { + return chatRoomFixtureBuilder.chatRoom() + .isGroup(isGroup) + .create(); + } + + public ChatRoom 멘토링_채팅방(long mentoringId) { + return chatRoomFixtureBuilder.chatRoom() + .mentoringId(mentoringId) + .isGroup(false) + .create(); + } +} +``` + +**편의 메서드 작성 팁:** + +- 한국어 메서드명 사용 (가독성) +- 자주 사용되는 기본값 조합만 제공 +- Builder를 조합하여 필요한 데이터 설정 + +### 테스트에서 사용 + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Test + void 채팅방을_생성할_수_있다() { + // 편의 메서드 사용 + ChatRoom room = chatRoomFixture.채팅방(false); + + // Builder 직접 사용 + ChatRoom customRoom = chatRoomFixture.chatRoomFixtureBuilder.chatRoom() + .isGroup(true) + .mentoringId(100L) + .create(); + } +} +``` + +## 테스트 네이밍 컨벤션 + +### 테스트 메서드 네이밍 규칙 + +테스트 메서드명은 **한국어로 명확하게** 작성하며, 다음 패턴을 따릅니다: + +#### 1. 정상 동작 테스트 + +```java +// 패턴: 어떤_것을_하면_어떤_결과가_나온다 +@Test +void 채팅방이_없으면_빈_목록을_반환한다() { ... } + +@Test +void 최신_메시지_순으로_정렬되어_조회한다() { ... } + +@Test +void 참여자는_메시지를_전송할_수_있다() { ... } + +@Test +void 페이징이_정상_작동한다() { ... } +``` + +#### 2. 예외 테스트 + +```java +// 패턴: 어떤_것을_하면_예외_응답을_반환한다 +@Test +void 참여하지_않은_채팅방에_접근하면_예외_응답을_반환한다() { ... } + +@Test +void 존재하지_않는_사용자로_메시지를_전송하면_예외_응답을_반환한다() { ... } + +@Test +void 권한이_없으면_예외_응답을_반환한다() { ... } + +@Test +void 필수_파라미터가_없으면_예외_응답을_반환한다() { ... } +``` + +## BDD 테스트 작성 + +테스트는 Given-When-Then 구조로 작성합니다. + +```java +@Test +@DisplayName("최신 메시지순으로 채팅방 목록을 조회한다") +void 최신_메시지_순으로_조회한다() { + // Given: 테스트 사전 조건 + SiteUser user = siteUserFixture.사용자(); + ChatRoom room1 = chatRoomFixture.채팅방(false); + ChatRoom room2 = chatRoomFixture.채팅방(false); + chatMessageFixture.메시지("오래된 메시지", user.getId(), room1); + chatMessageFixture.메시지("최신 메시지", user.getId(), room2); + + // When: 실제 동작 + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // Then: 결과 검증 + assertAll( + () -> assertThat(response.chatRooms()).hasSize(2), + () -> assertThat(response.chatRooms().get(0).id()).isEqualTo(room2.getId()) + ); +} +``` + +## 테스트 그룹화 (@Nested) + +기능별로 테스트를 그룹화합니다. + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Nested + @DisplayName("채팅방 목록 조회") + class 채팅방_목록을_조회한다 { + + @Test + void 빈_목록을_반환한다() { ... } + + @Test + void 최신_메시지_순으로_조회한다() { ... } + } + + @Nested + @DisplayName("채팅 메시지 전송") + class 채팅_메시지를_전송한다 { + + @BeforeEach + void setUp() { + // 이 그룹에만 적용되는 초기 설정 + } + + @Test + void 참여자는_메시지를_전송할_수_있다() { ... } + } +} +``` + +## 자주 사용하는 Assertion + +```java +// 기본 검증 +assertThat(value).isEqualTo(expected); +assertThat(value).isNotNull(); + +// 컬렉션 +assertThat(list).hasSize(3); +assertThat(list).isEmpty(); +assertThat(list).contains(item); + +// 예외 검증 +assertThatCode(() -> method()) + .isInstanceOf(CustomException.class) + .hasMessage("error message"); + +// 복수 검증 +assertAll( + () -> assertThat(a).isEqualTo(1), + () -> assertThat(b).isEqualTo(2) +); +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9d85f6083 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,388 @@ +# AGENTS.md + +이 파일은 Codex가 solid-connect-server 저장소에서 작업할 때 참고하는 가이드입니다. + +## 프로젝트 개요 + +Solid Connect Server는 교환학생 준비생을 위해 대학 정보, 멘토 매칭, 모의지원 기능 등을 제공하는 교환학생 지원 통합 플랫폼입니다. + +- **언어**: Java 21 +- **프레임워크**: Spring Boot 3.5.11 +- **빌드 도구**: Gradle +- **데이터베이스**: MySQL (주), Redis (캐싱) +- **마이그레이션**: Flyway + +--- + +## 빌드 및 개발 명령어 + +### Gradle 빌드 명령어 + +```bash +# 전체 빌드 +./gradlew build + +# 테스트 실행 +./gradlew test + +# 특정 테스트만 실행 +./gradlew test --tests ChatServiceTest + +# 애플리케이션 실행 +./gradlew bootRun + +# 로컬 개발 환경 시작 (MySQL, Redis) +docker-compose -f docker-compose.local.yml up -d +``` + +### 프로필별 실행 + +```bash +# 로컬 개발 환경 +./gradlew bootRun --args='--spring.profiles.active=local' + +# 개발 환경 +./gradlew bootRun --args='--spring.profiles.active=dev' + +# 운영 환경 +./gradlew bootRun --args='--spring.profiles.active=prod' +``` + +--- + +## 프로젝트 구조 + +``` +solid-connect-server/ +├── src/ +│ ├── main/ +│ │ ├── java/com/example/solidconnection/ +│ │ │ ├── [domain]/ # 도메인별 폴더 +│ │ │ │ ├── controller/ # REST API 엔드포인트 +│ │ │ │ ├── service/ # 비즈니스 로직 +│ │ │ │ ├── domain/ # JPA Entity +│ │ │ │ ├── repository/ # 데이터 접근 계층 +│ │ │ │ └── dto/ # DTO (Request/Response) +│ │ │ └── common/ # 공통 기능 +│ │ │ ├── exception/ # 커스텀 예외 +│ │ │ ├── config/ # Spring 설정 +│ │ │ └── util/ # 유틸리티 +│ │ └── resources/ +│ │ ├── db/migration/ # Flyway 마이그레이션 +│ │ └── application*.yml # 설정 파일 +│ └── test/ +│ └── java/com/example/solidconnection/ +│ ├── [domain]/fixture/ # 테스트 Fixture +│ ├── [domain]/service/ # 서비스 테스트 +│ ├── support/ # 테스트 설정 +│ └── ... +├── docker-compose.local.yml # 로컬 컨테이너 +├── docker-compose.dev.yml # 개발 컨테이너 +├── docker-compose.prod.yml # 운영 컨테이너 +├── Dockerfile # 이미지 빌드 +├── build.gradle # Gradle 설정 +``` + +--- + +## 아키텍처 + +### 계층형 아키텍처 (Layered Architecture) + +각 계층은 자신의 바로 아래 계층만 참조할 수 있습니다. + +``` +Controller → Service → Repository/Domain +``` + +**각 계층의 역할:** + +- **Controller**: HTTP 요청 처리, 입력값 검증, 응답 포맷팅 +- **Service**: 비즈니스 로직 처리, DTO 변환, 트랜잭션 관리 +- **Repository**: 데이터 접근 계층, DB 쿼리 작성 +- **Domain (Entity)**: JPA 엔티티, 도메인 모델 + +**주요 규칙:** + +- ✅ 역계층 참조 금지 (예: Repository에서 Service 참조 불가) +- ✅ Service는 Repository를 주입받아 사용 +- ✅ Controller는 Service를 주입받아 사용 +- ✅ Entity는 도메인 로직만 포함 +- ✅ DTO는 요청/응답 시에만 사용 + +### 패키지 구조 + +``` +[domain]/ +├── controller/ # REST API 엔드포인트 +├── service/ # 비즈니스 로직 (Service) +├── domain/ # JPA Entity +├── repository/ # 데이터 접근 계층 (Repository) +└── dto/ # DTO (Request/Response) +``` + +--- + +## 개발 컨벤션 + +### 코드 스타일 + +프로젝트의 개발 컨벤션을 따릅니다: [개발-컨벤션-정리](https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리) + +**주요 규칙:** + +- **클래스 선언 전 줄바꿈**: 클래스 정의 앞에 빈 줄 필수 +- **파일 끝 줄바꿈**: 모든 파일은 개행 문자로 종료 +- **와일드카드 import 금지**: 명시적 import만 사용 +- **파라미터 줄바꿈**: Controller는 필수, 3개 이상의 파라미터가 있으면 줄바꿈 +- **private 메서드 위치**: 호출하는 public 메서드 바로 아래 위치 +- **원시 타입 사용**: null이 아닌 값은 `int`, `long` 등 원시 타입 사용, nullable은 Wrapper 사용 +- **JPA @Column**: Entity의 모든 필드에 `@Column` 속성과 필드명 지정 + +### 네이밍 컨벤션 + +```java +// DTO 변환 +// 다중 파라미터: of() 메서드 +public static UserDto of(User user, Profile profile) { ...} + +// 단일 파라미터: from() 메서드 +public static UserDto from(User user) { ...} + +// API 요청/응답 +// XXXRequest, XXXResponse 형식 +public class UserCreateRequest { ... +} + +public class UserCreateResponse { ... +} + +// REST API 엔드포인트 +// kebab-case 사용 +@GetMapping("/user-profile") // O +@GetMapping("/userProfile") // X +``` + +--- + +## 기술 스택 상세 + +### Core Framework + +- **Spring Boot 3.5.11**: 스프링 부트 +- **Spring Security**: JWT 기반 인증 +- **Spring Data JPA**: ORM +- **QueryDSL**: 동적 쿼리 생성 + +### 데이터베이스 + +- **MySQL**: 주 데이터베이스 +- **Redis**: 캐싱 저장소 +- **Flyway**: 데이터베이스 버전 관리 + +### 모니터링 & 보안 + +- **Spring Boot Actuator**: 애플리케이션 모니터링 +- **Prometheus**: 메트릭 수집 +- **Sentry**: 에러 추적 +- **JWT**: JWT 토큰 관리 + +### 개발 도구 + +- **Lombok**: 보일러플레이트 코드 감소 +- **AWS S3 SDK**: 파일 저장소 +- **WebSocket**: 실시간 통신 +- **TestContainers**: 통합 테스트용 컨테이너 + +--- + +## 테스트 코드 작성 + +테스트 작성 시 `/test` skill을 참고하세요. (테스트 관련 작업 시 자동으로 로드됩니다) + +- `@TestContainerSpringBootTest` 기반 통합 테스트 +- FixtureBuilder + Fixture 패턴으로 테스트 데이터 생성 +- 한국어 메서드명, Given-When-Then 구조, @Nested 그룹화 + +--- + +## Git 커밋 컨벤션 + +### 형식 + +``` +: + +[optional body] +``` + +### Type 목록 + +``` +feat: 새로운 기능 추가 +fix: 버그 수정 +refactor: 코드 리팩토링 (기능 변경 없음) +docs: 문서 변경 +test: 테스트 추가/수정 +chore: 빌드 설정, 패키지 관리 +perf: 성능 개선 +``` + +### 예제 + +```bash +# 기능 추가 +feat: 대학 검색 기능 추가 + +# 버그 수정 +fix: 채팅방 조회 시 정렬 버그 수정 + +# 리팩토링 +refactor: ChatService 메서드 분리 + +# 테스트 추가 +test: ChatService 테스트 케이스 추가 + +# 브랜치명 +refactor/529-shortening-cd-time +``` + +--- + +## 데이터베이스 마이그레이션 + +### Flyway 사용 + +모든 DB 스키마 변경사항은 Flyway로 관리합니다. + +**위치:** `src/main/resources/db/migration/` + +**파일명 형식:** `V{VERSION}__{DESCRIPTION}.sql` + +``` +V1__init_schema.sql +V2__add_chat_table.sql +V3__add_user_role_column.sql +``` + +### 마이그레이션 추가 + +1. `V{next_version}__{description}.sql` 파일 생성 +2. SQL 작성 +3. `./gradlew build` 시 자동 검증 (flywayValidate) + +**주의:** 한 번 배포된 마이그레이션은 수정 불가 (새 버전으로 생성) + +--- + +## 데이터베이스 접근 + +### JPA Entity + +```java + +@Entity +@Table(name = "chat_room") +public class ChatRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "is_group", nullable = false) + private boolean isGroup; + + @Column(name = "mentoring_id", nullable = true) + private Long mentoringId; +} +``` + +**규칙:** + +- `@Column` 필수 (모든 필드) +- 필드명과 DB 컬럼명 일치 +- nullable 명시 + +### Repository + +```java +public interface ChatRoomRepository extends JpaRepository { + + Optional findByMentoringId(Long mentoringId); + + List findByIsGroup(boolean isGroup); +} +``` + +--- + +## 주요 파일 위치 + +| 파일/폴더 | 설명 | +|----------------------------------------------|---------------| +| `src/main/java/com/example/solidconnection/` | 메인 소스 코드 | +| `src/test/java/com/example/solidconnection/` | 테스트 코드 | +| `src/main/resources/db/migration/` | Flyway 마이그레이션 | +| `src/main/resources/application.yml` | 공통 설정 | +| `docker-compose.*.yml` | 환경별 도커 설정 | +| `build.gradle` | Gradle 빌드 설정 | + +--- + +### 프로필 + +- **local**: Development with embedded Tomcat +- **dev**: Development server (stage.solid-connection.com) +- **prod**: Production server (solid-connection.com) + +--- + +## 자주하는 작업 + +### 새 기능 추가 + +1. Entity 생성 (`src/main/java/.../domain/`) +2. Repository 작성 (`src/main/java/.../repository/`) +3. Service 구현 (`src/main/java/.../service/`) +4. Controller 작성 (`src/main/java/.../controller/`) +5. DTO 정의 (`src/main/java/.../dto/`) +6. Flyway 마이그레이션 작성 +7. 테스트 코드 작성 + +### 테스트 작성 + +1. FixtureBuilder 생성 (필요시) +2. Fixture 편의 메서드 추가 (필요시) +3. 테스트 클래스 작성 (`*Test.java`) +4. @Nested로 테스트 그룹화 +5. Given-When-Then 구조로 작성 +6. `./gradlew test` 실행 + +### DB 스키마 변경 + +1. `V{next}__{description}.sql` 파일 생성 +2. 마이그레이션 SQL 작성 +3. Entity 업데이트 (필요시) +4. 테스트 실행 + +--- + +## 참고 자료 + +- **개발 컨벤션**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 +- **테스트 가이드**: `test.md` 파일 참고 +- **Spring Boot**: https://spring.io/projects/spring-boot +- **JPA**: https://spring.io/projects/spring-data-jpa +- **TestContainers**: https://www.testcontainers.org/ +- **Flyway**: https://flywaydb.org/ + +--- + +## 주의사항 + +1. **Flyway 마이그레이션은 되돌릴 수 없음** - 신중하게 작성 +2. **QueryDSL Q클래스는 자동 생성** - 수동 수정 금지 +3. **테스트는 독립적** - 테스트 간 데이터 공유 불가 +4. **환경별 설정 분리** - application-local.yml, application-dev.yml, application-prod.yml +5. **한국어 메서드명** - 테스트 가독성 향상을 위해 사용 From 8478fb4439e04ef05057866e97dad5e6614f7fea Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 14:56:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20post-edit-check=20=ED=9B=85=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=98=AC=EB=B0=94=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EA=B2=8C=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/hooks/post-edit-check.py | 5 +++-- .claude/settings.json | 2 +- .codex/hooks.json | 15 +++++++++++++++ .codex/hooks/post-edit-check.py | 5 +++-- 4 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .codex/hooks.json diff --git a/.claude/hooks/post-edit-check.py b/.claude/hooks/post-edit-check.py index 0b5c1b14c..0edb391e2 100755 --- a/.claude/hooks/post-edit-check.py +++ b/.claude/hooks/post-edit-check.py @@ -43,6 +43,7 @@ warnings.append(f"L{i + 1}: Entity 필드에 @Column 누락 가능성: {line.strip()}") if warnings: - print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]") + print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]", file=sys.stderr) for w in warnings: - print(f" - {w}") + print(f" - {w}", file=sys.stderr) + sys.exit(2) diff --git a/.claude/settings.json b/.claude/settings.json index f6c5b8ec9..28d542b5e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -20,7 +20,7 @@ "hooks": [ { "type": "command", - "command": "python3 .claude/hooks/post-edit-check.py 2>/dev/null || python .claude/hooks/post-edit-check.py" + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .claude/hooks/post-edit-check.py || exec python .claude/hooks/post-edit-check.py" } ] } diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 000000000..74ef76c7d --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .codex/hooks/post-edit-check.py || exec python .codex/hooks/post-edit-check.py" + } + ] + } + ] + } +} diff --git a/.codex/hooks/post-edit-check.py b/.codex/hooks/post-edit-check.py index 0b5c1b14c..0edb391e2 100644 --- a/.codex/hooks/post-edit-check.py +++ b/.codex/hooks/post-edit-check.py @@ -43,6 +43,7 @@ warnings.append(f"L{i + 1}: Entity 필드에 @Column 누락 가능성: {line.strip()}") if warnings: - print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]") + print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]", file=sys.stderr) for w in warnings: - print(f" - {w}") + print(f" - {w}", file=sys.stderr) + sys.exit(2) From 2d445bfe109ce41cff30049e31167950fd570c05 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 18:36:01 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=99=20=EC=A0=81=EC=9E=AC=20=EC=8A=A4=ED=82=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/load-universities/SKILL.md | 103 +++ .../scripts/ingest_universities.py | 723 ++++++++++++++++++ .../university_ingestion_template.csv | 2 + .codex/skills/load-universities/SKILL.md | 103 +++ .../scripts/ingest_universities.py | 723 ++++++++++++++++++ .../university_ingestion_template.csv | 2 + .../AdminUnivApplyInfoController.java | 16 +- .../dto/UnivApplyInfoImportRequest.java | 21 - .../dto/UnivApplyInfoImportResponse.java | 9 - .../service/AdminUnivApplyInfoRowSaver.java | 203 ----- .../service/AdminUnivApplyInfoService.java | 54 +- .../common/util/MarkdownTableParser.java | 52 -- .../repository/UnivApplyInfoRepository.java | 16 + .../AdminUnivApplyInfoServiceTest.java | 368 ++------- .../common/util/MarkdownTableParserTest.java | 127 --- 15 files changed, 1744 insertions(+), 778 deletions(-) create mode 100644 .claude/skills/load-universities/SKILL.md create mode 100644 .claude/skills/load-universities/scripts/ingest_universities.py create mode 100644 .claude/skills/load-universities/templates/university_ingestion_template.csv create mode 100644 .codex/skills/load-universities/SKILL.md create mode 100644 .codex/skills/load-universities/scripts/ingest_universities.py create mode 100644 .codex/skills/load-universities/templates/university_ingestion_template.csv delete mode 100644 src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java delete mode 100644 src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java delete mode 100644 src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java delete mode 100644 src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java delete mode 100644 src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java diff --git a/.claude/skills/load-universities/SKILL.md b/.claude/skills/load-universities/SKILL.md new file mode 100644 index 000000000..e1b1e3bd5 --- /dev/null +++ b/.claude/skills/load-universities/SKILL.md @@ -0,0 +1,103 @@ +--- +name: load-universities +description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +--- + +# Load Universities + +Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. + +## Scope + +- Target only the approved dev API: `https://stage.solid-connection.com`. +- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. +- Never use the legacy Markdown import endpoint. +- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. +- Do not target local, prod, or an arbitrary URL. +- Do not mutate anything during preflight. + +## Files + +- Runner: `scripts/ingest_universities.py` +- CSV template: `templates/university_ingestion_template.csv` + +The `.claude/skills/load-universities` and `.codex/skills/load-universities` copies must stay behaviorally identical. + +## Input Schema + +Required columns: + +- `term_name`: term name in `YYYY-N` format. +- `home_university_name` +- `home_max_choice_count`: required when the home university does not already exist. +- `host_korean_name` +- `host_english_name`: required when the host university does not already exist. +- `host_format_name`: required when the host university does not already exist. +- `country_code`: required when the host university does not already exist. +- `region_code`: required when the host university does not already exist. + +Optional columns: + +- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. +- `home_email_domain` +- `student_capacity` +- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. +- `semester_requirement` +- `details_for_language` +- `gpa_requirement` +- `gpa_requirement_criteria` +- `details_for_accommodation` +- `extra_info`: JSON object, or `key=value;key2=value2`. +- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. +- `homepage_url` +- `english_course_url` +- `accommodation_url` +- `details_for_local` +- `logo_file`: local path or assets-dir relative path for missing host creation. +- `background_file`: local path or assets-dir relative path for missing host creation. + +## Commands + +Preflight only: + +```bash +python3 .claude/skills/load-universities/scripts/ingest_universities.py \ + --mode preflight \ + --input path/to/universities.csv \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Apply and verify: + +```bash +python3 .claude/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.xlsx \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Token-based authentication is also supported: + +```bash +python3 .claude/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.csv \ + --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" +``` + +## Workflow + +1. Validate the input file and dev base URL before authenticating. +2. Authenticate with either `--access-token` or admin email/password. +3. Parse every CSV/XLSX row and validate all required fields before mutation. +4. Read existing terms, home universities, and host universities through admin APIs. +5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. +6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. +7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. +8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. +9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. +10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. diff --git a/.claude/skills/load-universities/scripts/ingest_universities.py b/.claude/skills/load-universities/scripts/ingest_universities.py new file mode 100644 index 000000000..5c0e92303 --- /dev/null +++ b/.claude/skills/load-universities/scripts/ingest_universities.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""Dev-only Solid Connection university ingestion runner.""" + +from __future__ import annotations + +import argparse +import csv +import json +import mimetypes +import os +import re +import ssl +import sys +import uuid +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from xml.etree import ElementTree + + +APPROVED_DEV_BASE_URL = "https://stage.solid-connection.com" +TERM_RE = re.compile(r"^\d{4}-\d$") +ALLOWED_SEMESTERS = { + "ONE_SEMESTER", + "TWO_SEMESTER", + "FOUR_SEMESTER", + "ONE_OR_TWO_SEMESTER", + "ONE_YEAR", + "IRRELEVANT", + "NO_DATA", +} +LANGUAGE_TEST_TYPES = { + "CEFR", + "JLPT", + "DALF", + "DELF", + "DELE", + "DUOLINGO", + "IELTS", + "NEW_HSK", + "TCF", + "TEF", + "TOEFL_IBT", + "TOEFL_ITP", + "TOEIC", + "ETC", +} + + +class IngestionError(Exception): + pass + + +@dataclass(frozen=True) +class ParsedRow: + row_number: int + term_name: str + home_university_name: str + home_max_choice_count: int | None + home_email_domain: str | None + host_korean_name: str + host_english_name: str | None + host_format_name: str | None + country_code: str | None + region_code: str | None + univ_apply_info_id: int | None + student_capacity: int | None + semester_available_for_dispatch: str | None + semester_requirement: str | None + details_for_language: str | None + gpa_requirement: str | None + gpa_requirement_criteria: str | None + details_for_accommodation: str | None + extra_info: dict[str, str] + language_requirements: list[dict[str, str]] + homepage_url: str | None + english_course_url: str | None + accommodation_url: str | None + details_for_local: str | None + logo_file: str | None + background_file: str | None + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def empty_to_none(value: Any) -> str | None: + text = clean(value) + return text or None + + +def parse_int(value: Any, field: str, row_number: int) -> int | None: + text = clean(value) + if not text: + return None + try: + return int(float(text)) + except ValueError as exc: + raise IngestionError(f"row {row_number}: {field} must be an integer") from exc + + +def require(value: Any, field: str, row_number: int) -> str: + text = clean(value) + if not text: + raise IngestionError(f"row {row_number}: missing required field {field}") + return text + + +def parse_extra_info(value: Any, row_number: int) -> dict[str, str]: + text = clean(value) + if not text: + return {} + if text.startswith("{"): + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise IngestionError(f"row {row_number}: extra_info JSON must be an object") + return {str(k): "" if v is None else str(v) for k, v in parsed.items()} + result: dict[str, str] = {} + for part in text.split(";"): + if not part.strip(): + continue + if "=" not in part: + raise IngestionError(f"row {row_number}: extra_info entry must be key=value") + key, item_value = part.split("=", 1) + result[key.strip()] = item_value.strip() + return result + + +def parse_language_requirements(value: Any, row_number: int) -> list[dict[str, str]]: + text = clean(value) + if not text: + return [] + if text.startswith("[") or text.startswith("{"): + parsed = json.loads(text) + if isinstance(parsed, dict): + items = [ + {"languageTestType": str(k), "minScore": str(v)} + for k, v in parsed.items() + ] + elif isinstance(parsed, list): + items = parsed + else: + raise IngestionError(f"row {row_number}: language_requirements JSON must be an object or array") + else: + items = [] + for part in text.split(";"): + if not part.strip(): + continue + if ":" not in part: + raise IngestionError(f"row {row_number}: language requirement must be TYPE:score") + test_type, min_score = part.split(":", 1) + items.append({"languageTestType": test_type.strip(), "minScore": min_score.strip()}) + + normalized = [] + for item in items: + if not isinstance(item, dict): + raise IngestionError(f"row {row_number}: each language requirement must be an object") + test_type = clean(item.get("languageTestType")) + min_score = clean(item.get("minScore")) + if test_type not in LANGUAGE_TEST_TYPES: + raise IngestionError(f"row {row_number}: unsupported language test type {test_type}") + if not min_score: + raise IngestionError(f"row {row_number}: language minScore is required") + normalized.append({"languageTestType": test_type, "minScore": min_score}) + return sorted(normalized, key=lambda lr: (lr["languageTestType"], lr["minScore"])) + + +def read_csv(path: Path) -> list[dict[str, Any]]: + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def xlsx_cell_value(cell: ElementTree.Element, shared_strings: list[str], ns: dict[str, str]) -> str: + cell_type = cell.attrib.get("t") + value_node = cell.find("x:v", ns) + if value_node is None: + inline = cell.find("x:is/x:t", ns) + return inline.text if inline is not None and inline.text is not None else "" + value = value_node.text or "" + if cell_type == "s": + return shared_strings[int(value)] + return value + + +def column_index(cell_ref: str) -> int: + letters = re.sub(r"[^A-Z]", "", cell_ref.upper()) + index = 0 + for char in letters: + index = index * 26 + ord(char) - ord("A") + 1 + return index - 1 + + +def read_xlsx(path: Path) -> list[dict[str, Any]]: + ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + with zipfile.ZipFile(path) as archive: + shared_strings: list[str] = [] + if "xl/sharedStrings.xml" in archive.namelist(): + root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) + for item in root.findall("x:si", ns): + shared_strings.append("".join(t.text or "" for t in item.findall(".//x:t", ns))) + sheet_name = "xl/worksheets/sheet1.xml" + root = ElementTree.fromstring(archive.read(sheet_name)) + rows: list[list[str]] = [] + for row in root.findall(".//x:sheetData/x:row", ns): + values: list[str] = [] + for cell in row.findall("x:c", ns): + idx = column_index(cell.attrib.get("r", "A1")) + while len(values) <= idx: + values.append("") + values[idx] = xlsx_cell_value(cell, shared_strings, ns) + rows.append(values) + if not rows: + return [] + headers = [clean(h) for h in rows[0]] + result = [] + for row in rows[1:]: + if not any(clean(v) for v in row): + continue + result.append({headers[i]: row[i] if i < len(row) else "" for i in range(len(headers))}) + return result + + +def load_rows(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + raise IngestionError(f"input file not found: {path}") + suffix = path.suffix.lower() + if suffix == ".csv": + return read_csv(path) + if suffix == ".xlsx": + return read_xlsx(path) + raise IngestionError("supported input formats are .csv and .xlsx") + + +def parse_rows(raw_rows: list[dict[str, Any]]) -> list[ParsedRow]: + parsed = [] + for idx, raw in enumerate(raw_rows, start=2): + row = {clean(k): v for k, v in raw.items() if clean(k)} + term_name = require(row.get("term_name"), "term_name", idx) + if not TERM_RE.match(term_name): + raise IngestionError(f"row {idx}: term_name must match YYYY-N") + semester = empty_to_none(row.get("semester_available_for_dispatch")) + if semester and semester not in ALLOWED_SEMESTERS: + raise IngestionError(f"row {idx}: unsupported semester_available_for_dispatch {semester}") + parsed.append(ParsedRow( + row_number=idx, + term_name=term_name, + home_university_name=require(row.get("home_university_name"), "home_university_name", idx), + home_max_choice_count=parse_int(row.get("home_max_choice_count"), "home_max_choice_count", idx), + home_email_domain=empty_to_none(row.get("home_email_domain")), + host_korean_name=require(row.get("host_korean_name"), "host_korean_name", idx), + host_english_name=empty_to_none(row.get("host_english_name")), + host_format_name=empty_to_none(row.get("host_format_name")), + country_code=empty_to_none(row.get("country_code")), + region_code=empty_to_none(row.get("region_code")), + univ_apply_info_id=parse_int(row.get("univ_apply_info_id"), "univ_apply_info_id", idx), + student_capacity=parse_int(row.get("student_capacity"), "student_capacity", idx), + semester_available_for_dispatch=semester, + semester_requirement=empty_to_none(row.get("semester_requirement")), + details_for_language=empty_to_none(row.get("details_for_language")), + gpa_requirement=empty_to_none(row.get("gpa_requirement")), + gpa_requirement_criteria=empty_to_none(row.get("gpa_requirement_criteria")), + details_for_accommodation=empty_to_none(row.get("details_for_accommodation")), + extra_info=parse_extra_info(row.get("extra_info"), idx), + language_requirements=parse_language_requirements(row.get("language_requirements"), idx), + homepage_url=empty_to_none(row.get("homepage_url")), + english_course_url=empty_to_none(row.get("english_course_url")), + accommodation_url=empty_to_none(row.get("accommodation_url")), + details_for_local=empty_to_none(row.get("details_for_local")), + logo_file=empty_to_none(row.get("logo_file")), + background_file=empty_to_none(row.get("background_file")), + )) + if not parsed: + raise IngestionError("input contains no data rows") + return parsed + + +class ApiClient: + def __init__(self, base_url: str, access_token: str | None) -> None: + self.base_url = base_url.rstrip("/") + self.access_token = access_token + self.context = ssl.create_default_context() + + def request_json(self, method: str, path: str, body: Any | None = None, query: dict[str, Any] | None = None) -> Any: + url = self.base_url + path + if query: + url += "?" + urlencode({k: v for k, v in query.items() if v is not None}) + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, context=self.context, timeout=30) as response: + payload = response.read().decode("utf-8") + return json.loads(payload) if payload else None + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise IngestionError(f"{method} {path} failed: {exc.reason}") from exc + + def request_multipart(self, path: str, request_part: dict[str, Any], files: dict[str, Path]) -> Any: + boundary = "----solidconnection" + uuid.uuid4().hex + body = bytearray() + + def add_part(name: str, content: bytes, filename: str | None, content_type: str) -> None: + body.extend(f"--{boundary}\r\n".encode()) + disposition = f'Content-Disposition: form-data; name="{name}"' + if filename: + disposition += f'; filename="{filename}"' + body.extend((disposition + "\r\n").encode()) + body.extend(f"Content-Type: {content_type}\r\n\r\n".encode()) + body.extend(content) + body.extend(b"\r\n") + + add_part("request", json.dumps(request_part, ensure_ascii=False).encode("utf-8"), None, "application/json") + for field_name, path_value in files.items(): + content_type = mimetypes.guess_type(path_value.name)[0] or "application/octet-stream" + add_part(field_name, path_value.read_bytes(), path_value.name, content_type) + body.extend(f"--{boundary}--\r\n".encode()) + headers = { + "Accept": "application/json", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(self.base_url + path, data=bytes(body), headers=headers, method="POST") + try: + with urlopen(request, context=self.context, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"POST {path} failed with HTTP {exc.code}: {detail}") from exc + + def sign_in(self, email: str, password: str) -> None: + response = self.request_json("POST", "/admin/auth/sign-in", {"email": email, "password": password}) + token = response.get("accessToken") if isinstance(response, dict) else None + if not token: + raise IngestionError("admin sign-in response did not contain accessToken") + self.access_token = token + + +def enforce_dev_url(base_url: str) -> str: + normalized = base_url.rstrip("/") + if normalized != APPROVED_DEV_BASE_URL: + raise IngestionError(f"refusing non-dev target: {base_url}") + return normalized + + +def resolve_asset(path_text: str | None, assets_dir: Path | None) -> Path | None: + if not path_text: + return None + path = Path(path_text) + if not path.is_absolute() and assets_dir: + path = assets_dir / path + return path if path.exists() and path.is_file() else None + + +def fetch_all_terms(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/terms")} + + +def fetch_all_home_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/home-universities")} + + +def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + by_name: dict[str, dict[str, Any]] = {} + page = 0 + while True: + response = api.request_json("GET", "/admin/host-universities", query={"page": page, "size": 100}) + for item in response.get("content", []): + for name_key in ("koreanName", "englishName", "formatName"): + name = item.get(name_key) + if name: + by_name[name] = item + total_pages = int(response.get("totalPages", 0)) + page += 1 + if page >= total_pages: + break + return by_name + + +def validate_missing_entity_fields(rows: list[ParsedRow], homes: dict[str, Any], hosts: dict[str, Any]) -> None: + for row in rows: + if row.home_university_name not in homes and row.home_max_choice_count is None: + raise IngestionError(f"row {row.row_number}: home_max_choice_count is required for a missing home university") + if row.host_korean_name not in hosts: + for field_name, value in ( + ("host_english_name", row.host_english_name), + ("host_format_name", row.host_format_name), + ("country_code", row.country_code), + ("region_code", row.region_code), + ): + if not value: + raise IngestionError(f"row {row.row_number}: {field_name} is required for a missing host university") + + +def build_plan(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None) -> tuple[dict[str, Any], dict[str, Any]]: + terms = fetch_all_terms(api) + homes = fetch_all_home_universities(api) + hosts = fetch_all_host_universities(api) + validate_missing_entity_fields(rows, homes, hosts) + missing_assets = [] + lookup_failures = [] + will_create_apply_infos = 0 + will_update_apply_infos = 0 + for row in rows: + if row.host_korean_name in hosts: + term = terms.get(row.term_name) + home = homes.get(row.home_university_name) + host = hosts.get(row.host_korean_name) + if term and home and host: + try: + existing = find_existing_apply_info(api, row, term["id"], home["id"], host["id"]) + if existing: + will_update_apply_infos += 1 + else: + will_create_apply_infos += 1 + except IngestionError as exc: + lookup_failures.append({"row": row.row_number, "error": str(exc)}) + else: + will_create_apply_infos += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + missing_assets.append({ + "row": row.row_number, + "host_korean_name": row.host_korean_name, + "host_english_name": row.host_english_name, + "required": { + "logo_file": row.logo_file or f"{slug(row.host_english_name or row.host_korean_name)}-logo", + "background_file": row.background_file or f"{slug(row.host_english_name or row.host_korean_name)}-background", + }, + }) + will_create_apply_infos += 1 + status = "failed" if lookup_failures else "needs-assets" if missing_assets else "preflight-ok" + plan = { + "status": status, + "rows": len(rows), + "missing_assets": missing_assets, + "lookup_failures": lookup_failures, + "will_create_terms": sorted({r.term_name for r in rows if r.term_name not in terms}), + "will_create_home_universities": sorted({r.home_university_name for r in rows if r.home_university_name not in homes}), + "will_create_host_universities": sorted({r.host_korean_name for r in rows if r.host_korean_name not in hosts}), + "will_create_univ_apply_infos": will_create_apply_infos, + "will_update_univ_apply_infos": will_update_apply_infos, + } + indexes = {"terms": terms, "homes": homes, "hosts": hosts} + return plan, indexes + + +def slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def find_existing_apply_info( + api: ApiClient, + row: ParsedRow, + term_id: int, + home_id: int, + host_id: int, +) -> dict[str, Any] | None: + matches = api.request_json("GET", "/admin/univ-apply-infos", query={ + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + }) + if not isinstance(matches, list): + raise IngestionError("natural-key lookup did not return a list") + if len(matches) > 1: + raise IngestionError( + f"duplicate UnivApplyInfo rows for termId={term_id}, homeUniversityId={home_id}, hostUniversityId={host_id}" + ) + if row.univ_apply_info_id is not None: + if matches and int(matches[0]["id"]) != row.univ_apply_info_id: + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match natural-key row {matches[0]['id']}" + ) + if not matches: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{row.univ_apply_info_id}") + if ( + int(fetched.get("termId")) != int(term_id) + or int(fetched.get("homeUniversityId")) != int(home_id) + or int(fetched.get("hostUniversityId")) != int(host_id) + ): + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match the row's term/home/host natural key" + ) + return fetched + return matches[0] if matches else None + + +def apply_rows(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None, indexes: dict[str, Any]) -> dict[str, Any]: + counts = { + "terms_created": 0, + "terms_reused": 0, + "home_universities_created": 0, + "home_universities_reused": 0, + "host_universities_created": 0, + "host_universities_reused": 0, + "univ_apply_infos_created": 0, + "univ_apply_infos_updated": 0, + "failed": 0, + } + row_results = [] + terms = indexes["terms"] + homes = indexes["homes"] + hosts = indexes["hosts"] + + for term_name in sorted({r.term_name for r in rows}): + if term_name in terms: + counts["terms_reused"] += 1 + else: + created = api.request_json("POST", "/admin/terms", {"name": term_name}) + terms[term_name] = created + counts["terms_created"] += 1 + + for row in rows: + try: + home = homes.get(row.home_university_name) + if home: + counts["home_universities_reused"] += 1 + else: + home = api.request_json("POST", "/admin/home-universities", { + "name": row.home_university_name, + "maxChoiceCount": row.home_max_choice_count, + "emailDomain": row.home_email_domain, + }) + homes[row.home_university_name] = home + counts["home_universities_created"] += 1 + + host = hosts.get(row.host_korean_name) + if host: + counts["host_universities_reused"] += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + raise IngestionError("missing host university image files after preflight") + host = api.request_multipart("/admin/host-universities", { + "koreanName": row.host_korean_name, + "englishName": row.host_english_name, + "formatName": row.host_format_name, + "homepageUrl": row.homepage_url, + "englishCourseUrl": row.english_course_url, + "accommodationUrl": row.accommodation_url, + "detailsForLocal": row.details_for_local, + "countryCode": row.country_code, + "regionCode": row.region_code, + }, {"logoFile": logo_path, "backgroundFile": background_path}) + hosts[row.host_korean_name] = host + counts["host_universities_created"] += 1 + + payload = apply_payload(row) + existing_apply_info = find_existing_apply_info( + api, row, terms[row.term_name]["id"], home["id"], host["id"] + ) + if existing_apply_info is None: + response = api.request_json("POST", "/admin/univ-apply-infos", { + "termId": terms[row.term_name]["id"], + "homeUniversityId": home["id"], + "hostUniversityId": host["id"], + **payload, + }) + counts["univ_apply_infos_created"] += 1 + else: + response = api.request_json("PATCH", f"/admin/univ-apply-infos/{existing_apply_info['id']}", payload) + counts["univ_apply_infos_updated"] += 1 + + verification = verify_row(api, row, response["id"], terms[row.term_name]["id"], home["id"], host["id"]) + row_results.append({"row": row.row_number, "id": response["id"], "status": "verified", "verification": verification}) + except IngestionError as exc: + counts["failed"] += 1 + row_results.append({"row": row.row_number, "status": "failed", "error": str(exc)}) + + status = "verified" if counts["failed"] == 0 else "failed" + return {"status": status, "counts": counts, "rows": row_results} + + +def apply_payload(row: ParsedRow) -> dict[str, Any]: + return { + "studentCapacity": row.student_capacity, + "semesterAvailableForDispatch": row.semester_available_for_dispatch, + "semesterRequirement": row.semester_requirement, + "detailsForLanguage": row.details_for_language, + "gpaRequirement": row.gpa_requirement, + "gpaRequirementCriteria": row.gpa_requirement_criteria, + "detailsForAccommodation": row.details_for_accommodation, + "extraInfo": row.extra_info, + "languageRequirements": row.language_requirements, + } + + +def verify_row(api: ApiClient, row: ParsedRow, apply_info_id: int, term_id: int, home_id: int, host_id: int) -> dict[str, Any]: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{apply_info_id}") + expected = { + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + "koreanName": row.host_korean_name, + **apply_payload(row), + } + mismatches = [] + for key, expected_value in expected.items(): + actual = fetched.get(key) + if key == "languageRequirements": + actual = sorted(actual or [], key=lambda lr: (lr.get("languageTestType"), lr.get("minScore"))) + if actual != expected_value: + mismatches.append({"field": key, "expected": expected_value, "actual": actual}) + if mismatches: + raise IngestionError(f"verification mismatch for univ_apply_info {apply_info_id}: {json.dumps(mismatches, ensure_ascii=False)}") + return {"checked_fields": sorted(expected.keys())} + + +def output(payload: dict[str, Any]) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + + +def self_test() -> None: + sample = [{ + "term_name": "2026-1", + "home_university_name": "Inha", + "home_max_choice_count": "3", + "host_korean_name": "Sample", + "host_english_name": "Sample University", + "host_format_name": "Sample", + "country_code": "US", + "region_code": "US-CA", + "language_requirements": "TOEFL_IBT:80;IELTS:6.5", + "extra_info": "note=ok", + }] + parsed = parse_rows(sample) + assert parsed[0].language_requirements == [ + {"languageTestType": "IELTS", "minScore": "6.5"}, + {"languageTestType": "TOEFL_IBT", "minScore": "80"}, + ] + assert parsed[0].extra_info == {"note": "ok"} + + class FakeApi: + def request_json(self, method: str, path: str) -> dict[str, Any]: + assert method == "GET" + assert path == "/admin/univ-apply-infos/99" + return { + "termId": 1, + "homeUniversityId": 2, + "hostUniversityId": 3, + "koreanName": "Wrong Korean Name", + "studentCapacity": None, + "semesterAvailableForDispatch": None, + "semesterRequirement": None, + "detailsForLanguage": None, + "gpaRequirement": None, + "gpaRequirementCriteria": None, + "detailsForAccommodation": None, + "extraInfo": {"note": "ok"}, + "languageRequirements": parsed[0].language_requirements, + } + + try: + verify_row(FakeApi(), parsed[0], 99, 1, 2, 3) + except IngestionError as exc: + assert "koreanName" in str(exc) + else: + raise AssertionError("verify_row must fail when fetched koreanName does not match the input host_korean_name") + output({"status": "self-test-ok"}) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Ingest university data into Solid Connection dev.") + parser.add_argument("--mode", choices=["preflight", "apply"], required=False) + parser.add_argument("--input", type=Path) + parser.add_argument("--assets-dir", type=Path) + parser.add_argument("--dev-base-url", default=os.environ.get("SOLID_CONNECT_DEV_API_BASE", APPROVED_DEV_BASE_URL)) + parser.add_argument("--admin-email", default=os.environ.get("SOLID_CONNECT_ADMIN_EMAIL")) + parser.add_argument("--admin-password", default=os.environ.get("SOLID_CONNECT_ADMIN_PASSWORD")) + parser.add_argument("--access-token", default=os.environ.get("SOLID_CONNECT_ADMIN_ACCESS_TOKEN")) + parser.add_argument("--manifest-output", type=Path) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + if args.self_test: + self_test() + return 0 + if not args.mode or not args.input: + raise IngestionError("--mode and --input are required unless --self-test is used") + base_url = enforce_dev_url(args.dev_base_url) + raw_rows = load_rows(args.input) + rows = parse_rows(raw_rows) + api = ApiClient(base_url, args.access_token) + if not api.access_token: + if not args.admin_email or not args.admin_password: + raise IngestionError("provide --access-token or both --admin-email and --admin-password") + api.sign_in(args.admin_email, args.admin_password) + plan, indexes = build_plan(rows, api, args.assets_dir) + if args.manifest_output: + args.manifest_output.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + if args.mode == "preflight" or plan["status"] in {"needs-assets", "failed"}: + output(plan) + return 0 if plan["status"] in {"preflight-ok", "needs-assets"} else 1 + result = apply_rows(rows, api, args.assets_dir, indexes) + output(result) + return 0 if result["status"] == "verified" else 1 + except (IngestionError, json.JSONDecodeError, KeyError, zipfile.BadZipFile) as exc: + output({"status": "failed", "error": str(exc)}) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.claude/skills/load-universities/templates/university_ingestion_template.csv b/.claude/skills/load-universities/templates/university_ingestion_template.csv new file mode 100644 index 000000000..0b4d5a75e --- /dev/null +++ b/.claude/skills/load-universities/templates/university_ingestion_template.csv @@ -0,0 +1,2 @@ +term_name,home_university_name,home_max_choice_count,home_email_domain,host_korean_name,host_english_name,host_format_name,country_code,region_code,univ_apply_info_id,student_capacity,semester_available_for_dispatch,semester_requirement,details_for_language,gpa_requirement,gpa_requirement_criteria,details_for_accommodation,extra_info,language_requirements,homepage_url,english_course_url,accommodation_url,details_for_local,logo_file,background_file +2026-1,Inha University,3,inha.edu,Example University,Example University,Example University,US,US-CA,,2,ONE_SEMESTER,Spring dispatch only,TOEFL iBT accepted,3.0,4.5 scale,Dormitory available,"{""note"":""sample""}",TOEFL_IBT:80;IELTS:6.5,https://example.edu,https://example.edu/courses,https://example.edu/housing,Local notes,example-university-logo.png,example-university-background.jpg diff --git a/.codex/skills/load-universities/SKILL.md b/.codex/skills/load-universities/SKILL.md new file mode 100644 index 000000000..6dde1b127 --- /dev/null +++ b/.codex/skills/load-universities/SKILL.md @@ -0,0 +1,103 @@ +--- +name: load-universities +description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +--- + +# Load Universities + +Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. + +## Scope + +- Target only the approved dev API: `https://stage.solid-connection.com`. +- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. +- Never use the legacy Markdown import endpoint. +- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. +- Do not target local, prod, or an arbitrary URL. +- Do not mutate anything during preflight. + +## Files + +- Runner: `scripts/ingest_universities.py` +- CSV template: `templates/university_ingestion_template.csv` + +The `.claude/skills/load-universities` and `.codex/skills/load-universities` copies must stay behaviorally identical. + +## Input Schema + +Required columns: + +- `term_name`: term name in `YYYY-N` format. +- `home_university_name` +- `home_max_choice_count`: required when the home university does not already exist. +- `host_korean_name` +- `host_english_name`: required when the host university does not already exist. +- `host_format_name`: required when the host university does not already exist. +- `country_code`: required when the host university does not already exist. +- `region_code`: required when the host university does not already exist. + +Optional columns: + +- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. +- `home_email_domain` +- `student_capacity` +- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. +- `semester_requirement` +- `details_for_language` +- `gpa_requirement` +- `gpa_requirement_criteria` +- `details_for_accommodation` +- `extra_info`: JSON object, or `key=value;key2=value2`. +- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. +- `homepage_url` +- `english_course_url` +- `accommodation_url` +- `details_for_local` +- `logo_file`: local path or assets-dir relative path for missing host creation. +- `background_file`: local path or assets-dir relative path for missing host creation. + +## Commands + +Preflight only: + +```bash +python3 .codex/skills/load-universities/scripts/ingest_universities.py \ + --mode preflight \ + --input path/to/universities.csv \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Apply and verify: + +```bash +python3 .codex/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.xlsx \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Token-based authentication is also supported: + +```bash +python3 .codex/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.csv \ + --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" +``` + +## Workflow + +1. Validate the input file and dev base URL before authenticating. +2. Authenticate with either `--access-token` or admin email/password. +3. Parse every CSV/XLSX row and validate all required fields before mutation. +4. Read existing terms, home universities, and host universities through admin APIs. +5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. +6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. +7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. +8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. +9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. +10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. diff --git a/.codex/skills/load-universities/scripts/ingest_universities.py b/.codex/skills/load-universities/scripts/ingest_universities.py new file mode 100644 index 000000000..5c0e92303 --- /dev/null +++ b/.codex/skills/load-universities/scripts/ingest_universities.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""Dev-only Solid Connection university ingestion runner.""" + +from __future__ import annotations + +import argparse +import csv +import json +import mimetypes +import os +import re +import ssl +import sys +import uuid +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from xml.etree import ElementTree + + +APPROVED_DEV_BASE_URL = "https://stage.solid-connection.com" +TERM_RE = re.compile(r"^\d{4}-\d$") +ALLOWED_SEMESTERS = { + "ONE_SEMESTER", + "TWO_SEMESTER", + "FOUR_SEMESTER", + "ONE_OR_TWO_SEMESTER", + "ONE_YEAR", + "IRRELEVANT", + "NO_DATA", +} +LANGUAGE_TEST_TYPES = { + "CEFR", + "JLPT", + "DALF", + "DELF", + "DELE", + "DUOLINGO", + "IELTS", + "NEW_HSK", + "TCF", + "TEF", + "TOEFL_IBT", + "TOEFL_ITP", + "TOEIC", + "ETC", +} + + +class IngestionError(Exception): + pass + + +@dataclass(frozen=True) +class ParsedRow: + row_number: int + term_name: str + home_university_name: str + home_max_choice_count: int | None + home_email_domain: str | None + host_korean_name: str + host_english_name: str | None + host_format_name: str | None + country_code: str | None + region_code: str | None + univ_apply_info_id: int | None + student_capacity: int | None + semester_available_for_dispatch: str | None + semester_requirement: str | None + details_for_language: str | None + gpa_requirement: str | None + gpa_requirement_criteria: str | None + details_for_accommodation: str | None + extra_info: dict[str, str] + language_requirements: list[dict[str, str]] + homepage_url: str | None + english_course_url: str | None + accommodation_url: str | None + details_for_local: str | None + logo_file: str | None + background_file: str | None + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def empty_to_none(value: Any) -> str | None: + text = clean(value) + return text or None + + +def parse_int(value: Any, field: str, row_number: int) -> int | None: + text = clean(value) + if not text: + return None + try: + return int(float(text)) + except ValueError as exc: + raise IngestionError(f"row {row_number}: {field} must be an integer") from exc + + +def require(value: Any, field: str, row_number: int) -> str: + text = clean(value) + if not text: + raise IngestionError(f"row {row_number}: missing required field {field}") + return text + + +def parse_extra_info(value: Any, row_number: int) -> dict[str, str]: + text = clean(value) + if not text: + return {} + if text.startswith("{"): + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise IngestionError(f"row {row_number}: extra_info JSON must be an object") + return {str(k): "" if v is None else str(v) for k, v in parsed.items()} + result: dict[str, str] = {} + for part in text.split(";"): + if not part.strip(): + continue + if "=" not in part: + raise IngestionError(f"row {row_number}: extra_info entry must be key=value") + key, item_value = part.split("=", 1) + result[key.strip()] = item_value.strip() + return result + + +def parse_language_requirements(value: Any, row_number: int) -> list[dict[str, str]]: + text = clean(value) + if not text: + return [] + if text.startswith("[") or text.startswith("{"): + parsed = json.loads(text) + if isinstance(parsed, dict): + items = [ + {"languageTestType": str(k), "minScore": str(v)} + for k, v in parsed.items() + ] + elif isinstance(parsed, list): + items = parsed + else: + raise IngestionError(f"row {row_number}: language_requirements JSON must be an object or array") + else: + items = [] + for part in text.split(";"): + if not part.strip(): + continue + if ":" not in part: + raise IngestionError(f"row {row_number}: language requirement must be TYPE:score") + test_type, min_score = part.split(":", 1) + items.append({"languageTestType": test_type.strip(), "minScore": min_score.strip()}) + + normalized = [] + for item in items: + if not isinstance(item, dict): + raise IngestionError(f"row {row_number}: each language requirement must be an object") + test_type = clean(item.get("languageTestType")) + min_score = clean(item.get("minScore")) + if test_type not in LANGUAGE_TEST_TYPES: + raise IngestionError(f"row {row_number}: unsupported language test type {test_type}") + if not min_score: + raise IngestionError(f"row {row_number}: language minScore is required") + normalized.append({"languageTestType": test_type, "minScore": min_score}) + return sorted(normalized, key=lambda lr: (lr["languageTestType"], lr["minScore"])) + + +def read_csv(path: Path) -> list[dict[str, Any]]: + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def xlsx_cell_value(cell: ElementTree.Element, shared_strings: list[str], ns: dict[str, str]) -> str: + cell_type = cell.attrib.get("t") + value_node = cell.find("x:v", ns) + if value_node is None: + inline = cell.find("x:is/x:t", ns) + return inline.text if inline is not None and inline.text is not None else "" + value = value_node.text or "" + if cell_type == "s": + return shared_strings[int(value)] + return value + + +def column_index(cell_ref: str) -> int: + letters = re.sub(r"[^A-Z]", "", cell_ref.upper()) + index = 0 + for char in letters: + index = index * 26 + ord(char) - ord("A") + 1 + return index - 1 + + +def read_xlsx(path: Path) -> list[dict[str, Any]]: + ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + with zipfile.ZipFile(path) as archive: + shared_strings: list[str] = [] + if "xl/sharedStrings.xml" in archive.namelist(): + root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) + for item in root.findall("x:si", ns): + shared_strings.append("".join(t.text or "" for t in item.findall(".//x:t", ns))) + sheet_name = "xl/worksheets/sheet1.xml" + root = ElementTree.fromstring(archive.read(sheet_name)) + rows: list[list[str]] = [] + for row in root.findall(".//x:sheetData/x:row", ns): + values: list[str] = [] + for cell in row.findall("x:c", ns): + idx = column_index(cell.attrib.get("r", "A1")) + while len(values) <= idx: + values.append("") + values[idx] = xlsx_cell_value(cell, shared_strings, ns) + rows.append(values) + if not rows: + return [] + headers = [clean(h) for h in rows[0]] + result = [] + for row in rows[1:]: + if not any(clean(v) for v in row): + continue + result.append({headers[i]: row[i] if i < len(row) else "" for i in range(len(headers))}) + return result + + +def load_rows(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + raise IngestionError(f"input file not found: {path}") + suffix = path.suffix.lower() + if suffix == ".csv": + return read_csv(path) + if suffix == ".xlsx": + return read_xlsx(path) + raise IngestionError("supported input formats are .csv and .xlsx") + + +def parse_rows(raw_rows: list[dict[str, Any]]) -> list[ParsedRow]: + parsed = [] + for idx, raw in enumerate(raw_rows, start=2): + row = {clean(k): v for k, v in raw.items() if clean(k)} + term_name = require(row.get("term_name"), "term_name", idx) + if not TERM_RE.match(term_name): + raise IngestionError(f"row {idx}: term_name must match YYYY-N") + semester = empty_to_none(row.get("semester_available_for_dispatch")) + if semester and semester not in ALLOWED_SEMESTERS: + raise IngestionError(f"row {idx}: unsupported semester_available_for_dispatch {semester}") + parsed.append(ParsedRow( + row_number=idx, + term_name=term_name, + home_university_name=require(row.get("home_university_name"), "home_university_name", idx), + home_max_choice_count=parse_int(row.get("home_max_choice_count"), "home_max_choice_count", idx), + home_email_domain=empty_to_none(row.get("home_email_domain")), + host_korean_name=require(row.get("host_korean_name"), "host_korean_name", idx), + host_english_name=empty_to_none(row.get("host_english_name")), + host_format_name=empty_to_none(row.get("host_format_name")), + country_code=empty_to_none(row.get("country_code")), + region_code=empty_to_none(row.get("region_code")), + univ_apply_info_id=parse_int(row.get("univ_apply_info_id"), "univ_apply_info_id", idx), + student_capacity=parse_int(row.get("student_capacity"), "student_capacity", idx), + semester_available_for_dispatch=semester, + semester_requirement=empty_to_none(row.get("semester_requirement")), + details_for_language=empty_to_none(row.get("details_for_language")), + gpa_requirement=empty_to_none(row.get("gpa_requirement")), + gpa_requirement_criteria=empty_to_none(row.get("gpa_requirement_criteria")), + details_for_accommodation=empty_to_none(row.get("details_for_accommodation")), + extra_info=parse_extra_info(row.get("extra_info"), idx), + language_requirements=parse_language_requirements(row.get("language_requirements"), idx), + homepage_url=empty_to_none(row.get("homepage_url")), + english_course_url=empty_to_none(row.get("english_course_url")), + accommodation_url=empty_to_none(row.get("accommodation_url")), + details_for_local=empty_to_none(row.get("details_for_local")), + logo_file=empty_to_none(row.get("logo_file")), + background_file=empty_to_none(row.get("background_file")), + )) + if not parsed: + raise IngestionError("input contains no data rows") + return parsed + + +class ApiClient: + def __init__(self, base_url: str, access_token: str | None) -> None: + self.base_url = base_url.rstrip("/") + self.access_token = access_token + self.context = ssl.create_default_context() + + def request_json(self, method: str, path: str, body: Any | None = None, query: dict[str, Any] | None = None) -> Any: + url = self.base_url + path + if query: + url += "?" + urlencode({k: v for k, v in query.items() if v is not None}) + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, context=self.context, timeout=30) as response: + payload = response.read().decode("utf-8") + return json.loads(payload) if payload else None + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise IngestionError(f"{method} {path} failed: {exc.reason}") from exc + + def request_multipart(self, path: str, request_part: dict[str, Any], files: dict[str, Path]) -> Any: + boundary = "----solidconnection" + uuid.uuid4().hex + body = bytearray() + + def add_part(name: str, content: bytes, filename: str | None, content_type: str) -> None: + body.extend(f"--{boundary}\r\n".encode()) + disposition = f'Content-Disposition: form-data; name="{name}"' + if filename: + disposition += f'; filename="{filename}"' + body.extend((disposition + "\r\n").encode()) + body.extend(f"Content-Type: {content_type}\r\n\r\n".encode()) + body.extend(content) + body.extend(b"\r\n") + + add_part("request", json.dumps(request_part, ensure_ascii=False).encode("utf-8"), None, "application/json") + for field_name, path_value in files.items(): + content_type = mimetypes.guess_type(path_value.name)[0] or "application/octet-stream" + add_part(field_name, path_value.read_bytes(), path_value.name, content_type) + body.extend(f"--{boundary}--\r\n".encode()) + headers = { + "Accept": "application/json", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(self.base_url + path, data=bytes(body), headers=headers, method="POST") + try: + with urlopen(request, context=self.context, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"POST {path} failed with HTTP {exc.code}: {detail}") from exc + + def sign_in(self, email: str, password: str) -> None: + response = self.request_json("POST", "/admin/auth/sign-in", {"email": email, "password": password}) + token = response.get("accessToken") if isinstance(response, dict) else None + if not token: + raise IngestionError("admin sign-in response did not contain accessToken") + self.access_token = token + + +def enforce_dev_url(base_url: str) -> str: + normalized = base_url.rstrip("/") + if normalized != APPROVED_DEV_BASE_URL: + raise IngestionError(f"refusing non-dev target: {base_url}") + return normalized + + +def resolve_asset(path_text: str | None, assets_dir: Path | None) -> Path | None: + if not path_text: + return None + path = Path(path_text) + if not path.is_absolute() and assets_dir: + path = assets_dir / path + return path if path.exists() and path.is_file() else None + + +def fetch_all_terms(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/terms")} + + +def fetch_all_home_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/home-universities")} + + +def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + by_name: dict[str, dict[str, Any]] = {} + page = 0 + while True: + response = api.request_json("GET", "/admin/host-universities", query={"page": page, "size": 100}) + for item in response.get("content", []): + for name_key in ("koreanName", "englishName", "formatName"): + name = item.get(name_key) + if name: + by_name[name] = item + total_pages = int(response.get("totalPages", 0)) + page += 1 + if page >= total_pages: + break + return by_name + + +def validate_missing_entity_fields(rows: list[ParsedRow], homes: dict[str, Any], hosts: dict[str, Any]) -> None: + for row in rows: + if row.home_university_name not in homes and row.home_max_choice_count is None: + raise IngestionError(f"row {row.row_number}: home_max_choice_count is required for a missing home university") + if row.host_korean_name not in hosts: + for field_name, value in ( + ("host_english_name", row.host_english_name), + ("host_format_name", row.host_format_name), + ("country_code", row.country_code), + ("region_code", row.region_code), + ): + if not value: + raise IngestionError(f"row {row.row_number}: {field_name} is required for a missing host university") + + +def build_plan(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None) -> tuple[dict[str, Any], dict[str, Any]]: + terms = fetch_all_terms(api) + homes = fetch_all_home_universities(api) + hosts = fetch_all_host_universities(api) + validate_missing_entity_fields(rows, homes, hosts) + missing_assets = [] + lookup_failures = [] + will_create_apply_infos = 0 + will_update_apply_infos = 0 + for row in rows: + if row.host_korean_name in hosts: + term = terms.get(row.term_name) + home = homes.get(row.home_university_name) + host = hosts.get(row.host_korean_name) + if term and home and host: + try: + existing = find_existing_apply_info(api, row, term["id"], home["id"], host["id"]) + if existing: + will_update_apply_infos += 1 + else: + will_create_apply_infos += 1 + except IngestionError as exc: + lookup_failures.append({"row": row.row_number, "error": str(exc)}) + else: + will_create_apply_infos += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + missing_assets.append({ + "row": row.row_number, + "host_korean_name": row.host_korean_name, + "host_english_name": row.host_english_name, + "required": { + "logo_file": row.logo_file or f"{slug(row.host_english_name or row.host_korean_name)}-logo", + "background_file": row.background_file or f"{slug(row.host_english_name or row.host_korean_name)}-background", + }, + }) + will_create_apply_infos += 1 + status = "failed" if lookup_failures else "needs-assets" if missing_assets else "preflight-ok" + plan = { + "status": status, + "rows": len(rows), + "missing_assets": missing_assets, + "lookup_failures": lookup_failures, + "will_create_terms": sorted({r.term_name for r in rows if r.term_name not in terms}), + "will_create_home_universities": sorted({r.home_university_name for r in rows if r.home_university_name not in homes}), + "will_create_host_universities": sorted({r.host_korean_name for r in rows if r.host_korean_name not in hosts}), + "will_create_univ_apply_infos": will_create_apply_infos, + "will_update_univ_apply_infos": will_update_apply_infos, + } + indexes = {"terms": terms, "homes": homes, "hosts": hosts} + return plan, indexes + + +def slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def find_existing_apply_info( + api: ApiClient, + row: ParsedRow, + term_id: int, + home_id: int, + host_id: int, +) -> dict[str, Any] | None: + matches = api.request_json("GET", "/admin/univ-apply-infos", query={ + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + }) + if not isinstance(matches, list): + raise IngestionError("natural-key lookup did not return a list") + if len(matches) > 1: + raise IngestionError( + f"duplicate UnivApplyInfo rows for termId={term_id}, homeUniversityId={home_id}, hostUniversityId={host_id}" + ) + if row.univ_apply_info_id is not None: + if matches and int(matches[0]["id"]) != row.univ_apply_info_id: + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match natural-key row {matches[0]['id']}" + ) + if not matches: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{row.univ_apply_info_id}") + if ( + int(fetched.get("termId")) != int(term_id) + or int(fetched.get("homeUniversityId")) != int(home_id) + or int(fetched.get("hostUniversityId")) != int(host_id) + ): + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match the row's term/home/host natural key" + ) + return fetched + return matches[0] if matches else None + + +def apply_rows(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None, indexes: dict[str, Any]) -> dict[str, Any]: + counts = { + "terms_created": 0, + "terms_reused": 0, + "home_universities_created": 0, + "home_universities_reused": 0, + "host_universities_created": 0, + "host_universities_reused": 0, + "univ_apply_infos_created": 0, + "univ_apply_infos_updated": 0, + "failed": 0, + } + row_results = [] + terms = indexes["terms"] + homes = indexes["homes"] + hosts = indexes["hosts"] + + for term_name in sorted({r.term_name for r in rows}): + if term_name in terms: + counts["terms_reused"] += 1 + else: + created = api.request_json("POST", "/admin/terms", {"name": term_name}) + terms[term_name] = created + counts["terms_created"] += 1 + + for row in rows: + try: + home = homes.get(row.home_university_name) + if home: + counts["home_universities_reused"] += 1 + else: + home = api.request_json("POST", "/admin/home-universities", { + "name": row.home_university_name, + "maxChoiceCount": row.home_max_choice_count, + "emailDomain": row.home_email_domain, + }) + homes[row.home_university_name] = home + counts["home_universities_created"] += 1 + + host = hosts.get(row.host_korean_name) + if host: + counts["host_universities_reused"] += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + raise IngestionError("missing host university image files after preflight") + host = api.request_multipart("/admin/host-universities", { + "koreanName": row.host_korean_name, + "englishName": row.host_english_name, + "formatName": row.host_format_name, + "homepageUrl": row.homepage_url, + "englishCourseUrl": row.english_course_url, + "accommodationUrl": row.accommodation_url, + "detailsForLocal": row.details_for_local, + "countryCode": row.country_code, + "regionCode": row.region_code, + }, {"logoFile": logo_path, "backgroundFile": background_path}) + hosts[row.host_korean_name] = host + counts["host_universities_created"] += 1 + + payload = apply_payload(row) + existing_apply_info = find_existing_apply_info( + api, row, terms[row.term_name]["id"], home["id"], host["id"] + ) + if existing_apply_info is None: + response = api.request_json("POST", "/admin/univ-apply-infos", { + "termId": terms[row.term_name]["id"], + "homeUniversityId": home["id"], + "hostUniversityId": host["id"], + **payload, + }) + counts["univ_apply_infos_created"] += 1 + else: + response = api.request_json("PATCH", f"/admin/univ-apply-infos/{existing_apply_info['id']}", payload) + counts["univ_apply_infos_updated"] += 1 + + verification = verify_row(api, row, response["id"], terms[row.term_name]["id"], home["id"], host["id"]) + row_results.append({"row": row.row_number, "id": response["id"], "status": "verified", "verification": verification}) + except IngestionError as exc: + counts["failed"] += 1 + row_results.append({"row": row.row_number, "status": "failed", "error": str(exc)}) + + status = "verified" if counts["failed"] == 0 else "failed" + return {"status": status, "counts": counts, "rows": row_results} + + +def apply_payload(row: ParsedRow) -> dict[str, Any]: + return { + "studentCapacity": row.student_capacity, + "semesterAvailableForDispatch": row.semester_available_for_dispatch, + "semesterRequirement": row.semester_requirement, + "detailsForLanguage": row.details_for_language, + "gpaRequirement": row.gpa_requirement, + "gpaRequirementCriteria": row.gpa_requirement_criteria, + "detailsForAccommodation": row.details_for_accommodation, + "extraInfo": row.extra_info, + "languageRequirements": row.language_requirements, + } + + +def verify_row(api: ApiClient, row: ParsedRow, apply_info_id: int, term_id: int, home_id: int, host_id: int) -> dict[str, Any]: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{apply_info_id}") + expected = { + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + "koreanName": row.host_korean_name, + **apply_payload(row), + } + mismatches = [] + for key, expected_value in expected.items(): + actual = fetched.get(key) + if key == "languageRequirements": + actual = sorted(actual or [], key=lambda lr: (lr.get("languageTestType"), lr.get("minScore"))) + if actual != expected_value: + mismatches.append({"field": key, "expected": expected_value, "actual": actual}) + if mismatches: + raise IngestionError(f"verification mismatch for univ_apply_info {apply_info_id}: {json.dumps(mismatches, ensure_ascii=False)}") + return {"checked_fields": sorted(expected.keys())} + + +def output(payload: dict[str, Any]) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + + +def self_test() -> None: + sample = [{ + "term_name": "2026-1", + "home_university_name": "Inha", + "home_max_choice_count": "3", + "host_korean_name": "Sample", + "host_english_name": "Sample University", + "host_format_name": "Sample", + "country_code": "US", + "region_code": "US-CA", + "language_requirements": "TOEFL_IBT:80;IELTS:6.5", + "extra_info": "note=ok", + }] + parsed = parse_rows(sample) + assert parsed[0].language_requirements == [ + {"languageTestType": "IELTS", "minScore": "6.5"}, + {"languageTestType": "TOEFL_IBT", "minScore": "80"}, + ] + assert parsed[0].extra_info == {"note": "ok"} + + class FakeApi: + def request_json(self, method: str, path: str) -> dict[str, Any]: + assert method == "GET" + assert path == "/admin/univ-apply-infos/99" + return { + "termId": 1, + "homeUniversityId": 2, + "hostUniversityId": 3, + "koreanName": "Wrong Korean Name", + "studentCapacity": None, + "semesterAvailableForDispatch": None, + "semesterRequirement": None, + "detailsForLanguage": None, + "gpaRequirement": None, + "gpaRequirementCriteria": None, + "detailsForAccommodation": None, + "extraInfo": {"note": "ok"}, + "languageRequirements": parsed[0].language_requirements, + } + + try: + verify_row(FakeApi(), parsed[0], 99, 1, 2, 3) + except IngestionError as exc: + assert "koreanName" in str(exc) + else: + raise AssertionError("verify_row must fail when fetched koreanName does not match the input host_korean_name") + output({"status": "self-test-ok"}) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Ingest university data into Solid Connection dev.") + parser.add_argument("--mode", choices=["preflight", "apply"], required=False) + parser.add_argument("--input", type=Path) + parser.add_argument("--assets-dir", type=Path) + parser.add_argument("--dev-base-url", default=os.environ.get("SOLID_CONNECT_DEV_API_BASE", APPROVED_DEV_BASE_URL)) + parser.add_argument("--admin-email", default=os.environ.get("SOLID_CONNECT_ADMIN_EMAIL")) + parser.add_argument("--admin-password", default=os.environ.get("SOLID_CONNECT_ADMIN_PASSWORD")) + parser.add_argument("--access-token", default=os.environ.get("SOLID_CONNECT_ADMIN_ACCESS_TOKEN")) + parser.add_argument("--manifest-output", type=Path) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + if args.self_test: + self_test() + return 0 + if not args.mode or not args.input: + raise IngestionError("--mode and --input are required unless --self-test is used") + base_url = enforce_dev_url(args.dev_base_url) + raw_rows = load_rows(args.input) + rows = parse_rows(raw_rows) + api = ApiClient(base_url, args.access_token) + if not api.access_token: + if not args.admin_email or not args.admin_password: + raise IngestionError("provide --access-token or both --admin-email and --admin-password") + api.sign_in(args.admin_email, args.admin_password) + plan, indexes = build_plan(rows, api, args.assets_dir) + if args.manifest_output: + args.manifest_output.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + if args.mode == "preflight" or plan["status"] in {"needs-assets", "failed"}: + output(plan) + return 0 if plan["status"] in {"preflight-ok", "needs-assets"} else 1 + result = apply_rows(rows, api, args.assets_dir, indexes) + output(result) + return 0 if result["status"] == "verified" else 1 + except (IngestionError, json.JSONDecodeError, KeyError, zipfile.BadZipFile) as exc: + output({"status": "failed", "error": str(exc)}) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.codex/skills/load-universities/templates/university_ingestion_template.csv b/.codex/skills/load-universities/templates/university_ingestion_template.csv new file mode 100644 index 000000000..0b4d5a75e --- /dev/null +++ b/.codex/skills/load-universities/templates/university_ingestion_template.csv @@ -0,0 +1,2 @@ +term_name,home_university_name,home_max_choice_count,home_email_domain,host_korean_name,host_english_name,host_format_name,country_code,region_code,univ_apply_info_id,student_capacity,semester_available_for_dispatch,semester_requirement,details_for_language,gpa_requirement,gpa_requirement_criteria,details_for_accommodation,extra_info,language_requirements,homepage_url,english_course_url,accommodation_url,details_for_local,logo_file,background_file +2026-1,Inha University,3,inha.edu,Example University,Example University,Example University,US,US-CA,,2,ONE_SEMESTER,Spring dispatch only,TOEFL iBT accepted,3.0,4.5 scale,Dormitory available,"{""note"":""sample""}",TOEFL_IBT:80;IELTS:6.5,https://example.edu,https://example.edu/courses,https://example.edu/housing,Local notes,example-university-logo.png,example-university-background.jpg diff --git a/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java b/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java index 79d46506b..9996dd183 100644 --- a/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java +++ b/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java @@ -4,10 +4,9 @@ import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoResponse; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoUpdateRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; import com.example.solidconnection.admin.university.service.AdminUnivApplyInfoService; import jakarta.validation.Valid; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -17,6 +16,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -31,11 +31,15 @@ public ResponseEntity getFields() { return ResponseEntity.ok(adminUnivApplyInfoService.getFields()); } - @PostMapping("/import") - public ResponseEntity importUnivApplyInfos( - @Valid @RequestBody UnivApplyInfoImportRequest request + @GetMapping + public ResponseEntity> findUnivApplyInfos( + @RequestParam long termId, + @RequestParam long homeUniversityId, + @RequestParam long hostUniversityId ) { - return ResponseEntity.ok(adminUnivApplyInfoService.importUnivApplyInfos(request)); + return ResponseEntity.ok( + adminUnivApplyInfoService.findUnivApplyInfos(termId, homeUniversityId, hostUniversityId) + ); } @GetMapping("/{id}") diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java deleted file mode 100644 index e28dec9e2..000000000 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.solidconnection.admin.university.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import java.util.Map; - -public record UnivApplyInfoImportRequest( - @NotNull(message = "학기는 필수입니다") - Long termId, - - @NotNull(message = "대학은 필수입니다") - Long homeUniversityId, - - @NotBlank(message = "마크다운 텍스트는 필수입니다") - String markdown, - - @NotEmpty(message = "컬럼은 필수입니다") - Map columnMappings -) { -} diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java deleted file mode 100644 index 43eb10f09..000000000 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.solidconnection.admin.university.dto; - -import java.util.List; - -public record UnivApplyInfoImportResponse( - int successCount, - List createdUniversities -) { -} diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java deleted file mode 100644 index 5046cdeaa..000000000 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.example.solidconnection.admin.university.service; - -import static com.example.solidconnection.common.exception.ErrorCode.COUNTRY_NOT_FOUND; -import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; - -import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.location.country.domain.Country; -import com.example.solidconnection.location.country.repository.CountryRepository; -import com.example.solidconnection.location.region.domain.Region; -import com.example.solidconnection.location.region.repository.RegionRepository; -import com.example.solidconnection.university.domain.HomeUniversity; -import com.example.solidconnection.university.domain.HostUniversity; -import com.example.solidconnection.university.domain.LanguageRequirement; -import com.example.solidconnection.university.domain.LanguageTestType; -import com.example.solidconnection.university.domain.SemesterAvailableForDispatch; -import com.example.solidconnection.university.domain.UnivApplyInfo; -import com.example.solidconnection.university.repository.HostUniversityRepository; -import com.example.solidconnection.university.repository.UnivApplyInfoRepository; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class AdminUnivApplyInfoRowSaver { - - private final HostUniversityRepository hostUniversityRepository; - private final UnivApplyInfoRepository univApplyInfoRepository; - private final CountryRepository countryRepository; - private final RegionRepository regionRepository; - - @Transactional - public String save( - Map rowData, - Map columnMappings, - HomeUniversity homeUniversity, - long termId - ) { - ImportData data = buildImportData(rowData, columnMappings); - - boolean existed = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); - HostUniversity hostUniversity = findOrCreateHostUniversity(data); - String createdUniversityName = existed ? null : hostUniversity.getKoreanName(); - - UnivApplyInfo univApplyInfo = new UnivApplyInfo( - null, - termId, - homeUniversity, - data.universityKoreanName, - data.studentCapacity, - data.semesterAvailableForDispatch, - data.semesterRequirement, - data.detailsForLanguage, - data.gpaRequirement, - data.gpaRequirementCriteria, - data.detailsForAccommodation, - data.extraInfo.isEmpty() ? null : data.extraInfo, - new HashSet<>(), - hostUniversity - ); - - UnivApplyInfo saved = univApplyInfoRepository.save(univApplyInfo); - - data.languageRequirements.forEach((testType, minScore) -> { - LanguageRequirement lr = new LanguageRequirement(null, testType, minScore, saved); - saved.addLanguageRequirements(lr); - }); - - return createdUniversityName; - } - - private ImportData buildImportData(Map rowData, Map columnMappings) { - ImportData data = new ImportData(); - rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); - return data; - } - - private void applyField(ImportData data, String header, String value, Map columnMappings) { - String targetField = columnMappings.getOrDefault(header, "extraInfo"); - - if ("extraInfo".equals(targetField)) { - data.extraInfo.put(header, value); - return; - } - - try { - LanguageTestType testType = LanguageTestType.valueOf(targetField); - if (!value.isBlank()) { - data.languageRequirements.put(testType, value); - } - return; - } catch (IllegalArgumentException ignored) { - } - - applyStructuredField(data, header, targetField, value); - } - - private void applyStructuredField(ImportData data, String header, String fieldName, String value) { - switch (fieldName) { - case "universityKoreanName" -> applyWithLength(value, 100, s -> data.universityKoreanName = s); - case "universityEnglishName" -> applyWithLength(value, 200, s -> data.englishName = s); - case "universityFormatName" -> applyWithLength(value, 200, s -> data.formatName = s); - case "universityCountryCode" -> data.countryCode = value; - case "universityHomepageUrl" -> applyWithLength(value, 500, s -> data.homepageUrl = s); - case "universityEnglishCourseUrl" -> applyWithLength(value, 500, s -> data.englishCourseUrl = s); - case "universityAccommodationUrl" -> applyWithLength(value, 500, s -> data.accommodationUrl = s); - case "universityDetailsForLocal" -> applyWithLength(value, 1000, s -> data.detailsForLocal = s); - case "studentCapacity" -> { - try { - data.studentCapacity = Integer.parseInt(value); - } catch (NumberFormatException e) { - throw new CustomException(INVALID_INPUT, "선발 인원은 정수여야 합니다: '" + value + "'"); - } - } - case "semesterAvailableForDispatch" -> { - try { - data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); - } catch (IllegalArgumentException e) { - throw new CustomException(INVALID_INPUT, - "유효하지 않은 파견 가능 학기입니다. 가능한 값: " + validEnumValues(SemesterAvailableForDispatch.values())); - } - } - case "semesterRequirement" -> applyWithLength(value, 2000, s -> data.semesterRequirement = s); - case "detailsForLanguage" -> applyWithLength(value, 4000, s -> data.detailsForLanguage = s); - case "gpaRequirement" -> applyWithLength(value, 2000, s -> data.gpaRequirement = s); - case "gpaRequirementCriteria" -> applyWithLength(value, 100, s -> data.gpaRequirementCriteria = s); - case "detailsForAccommodation" -> applyWithLength(value, 2000, s -> data.detailsForAccommodation = s); - default -> data.extraInfo.put(header, value); - } - } - - private void applyWithLength(String value, int maxLength, Consumer setter) { - if (value.length() > maxLength) { - throw new CustomException(INVALID_INPUT, - "값이 최대 길이(" + maxLength + "자)를 초과했습니다: " + value.length() + "자"); - } - setter.accept(value); - } - - private String validEnumValues(Enum[] values) { - return Arrays.stream(values) - .map(Enum::name) - .collect(Collectors.joining(", ")); - } - - private HostUniversity findOrCreateHostUniversity(ImportData data) { - return hostUniversityRepository.findByKoreanName(data.universityKoreanName) - .orElseGet(() -> createHostUniversity(data)); - } - - private HostUniversity createHostUniversity(ImportData data) { - if (data.countryCode == null || data.countryCode.isBlank()) { - throw new CustomException(INVALID_INPUT, - "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); - } - - Country country = countryRepository.findByCode(data.countryCode) - .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); - Region region = regionRepository.findById(country.getRegionCode()).orElse(null); - - return hostUniversityRepository.save(new HostUniversity( - null, - data.universityKoreanName, - data.englishName != null ? data.englishName : "", - data.formatName != null ? data.formatName : "", - data.homepageUrl, - data.englishCourseUrl, - data.accommodationUrl, - "", - "", - data.detailsForLocal, - country, - region - )); - } - - private static class ImportData { - - String universityKoreanName; - String englishName; - String formatName; - String countryCode; - String homepageUrl; - String englishCourseUrl; - String accommodationUrl; - String detailsForLocal; - Integer studentCapacity; - SemesterAvailableForDispatch semesterAvailableForDispatch; - String semesterRequirement; - String detailsForLanguage; - String gpaRequirement; - String gpaRequirementCriteria; - String detailsForAccommodation; - Map extraInfo = new HashMap<>(); - Map languageRequirements = new HashMap<>(); - } -} diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index 93122007e..fe58facf1 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -1,7 +1,6 @@ package com.example.solidconnection.admin.university.service; import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND; -import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_HAS_REFERENCES; import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; @@ -11,12 +10,9 @@ import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoResponse; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoUpdateRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; import com.example.solidconnection.application.repository.ApplicationRepository; import com.example.solidconnection.cache.annotation.DefaultCacheOut; import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.common.util.MarkdownTableParser; import com.example.solidconnection.term.repository.TermRepository; import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.HostUniversity; @@ -26,10 +22,8 @@ import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; -import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,8 +34,6 @@ public class AdminUnivApplyInfoService { private final TermRepository termRepository; private final HomeUniversityRepository homeUniversityRepository; - private final MarkdownTableParser markdownTableParser; - private final AdminUnivApplyInfoRowSaver rowSaver; private final UnivApplyInfoRepository univApplyInfoRepository; private final HostUniversityRepository hostUniversityRepository; private final LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; @@ -51,39 +43,6 @@ public UnivApplyInfoFieldResponse getFields() { return UnivApplyInfoFieldResponse.of(); } - @Transactional - @DefaultCacheOut( - key = {"univApplyInfoTextSearch", "university:recommend:general"}, - cacheManager = "customCacheManager", - prefix = true - ) - public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportRequest request) { - validateColumnMappings(request.columnMappings()); - validateTermExists(request.termId()); - HomeUniversity homeUniversity = findHomeUniversity(request.homeUniversityId()); - - List> rows = markdownTableParser.parse(request.markdown()); - - List createdUniversities = new ArrayList<>(); - - for (Map row : rows) { - String createdName = rowSaver.save(row, request.columnMappings(), homeUniversity, request.termId()); - if (createdName != null) { - createdUniversities.add(createdName); - } - } - - return new UnivApplyInfoImportResponse(rows.size(), createdUniversities); - } - - private void validateColumnMappings(Map columnMappings) { - boolean hasBlankEntry = columnMappings.entrySet().stream() - .anyMatch(e -> e.getKey().isBlank() || e.getValue().isBlank()); - if (hasBlankEntry) { - throw new CustomException(INVALID_INPUT, "컬럼 매핑의 키와 값은 공백일 수 없습니다"); - } - } - private void validateTermExists(Long termId) { termRepository.findById(termId) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); @@ -141,6 +100,19 @@ private HostUniversity findHostUniversity(Long hostUniversityId) { .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); } + @Transactional(readOnly = true) + public List findUnivApplyInfos( + long termId, + long homeUniversityId, + long hostUniversityId + ) { + return univApplyInfoRepository.findAllByTermIdAndHomeUniversityIdAndUniversityId( + termId, homeUniversityId, hostUniversityId) + .stream() + .map(AdminUnivApplyInfoResponse::from) + .toList(); + } + @Transactional(readOnly = true) public AdminUnivApplyInfoResponse getUnivApplyInfo(long id) { UnivApplyInfo univApplyInfo = univApplyInfoRepository.findById(id) diff --git a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java deleted file mode 100644 index fc3d91eb9..000000000 --- a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.example.solidconnection.common.util; - -import static com.example.solidconnection.common.exception.ErrorCode.INVALID_MARKDOWN_FORMAT; - -import com.example.solidconnection.common.exception.CustomException; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import org.springframework.stereotype.Component; - -@Component -public class MarkdownTableParser { - - public List> parse(String markdown) { - String[] lines = markdown.trim().split("\n"); - validate(lines); - List headers = parseRow(lines[0]); - return Arrays.stream(lines) - .skip(2) - .filter(line -> !line.isBlank()) - .map(line -> buildRowMap(headers, parseRow(line))) - .filter(row -> !row.isEmpty()) - .collect(Collectors.toList()); - } - - private void validate(String[] lines) { - if (lines.length < 3 || !lines[1].contains("---")) { - throw new CustomException(INVALID_MARKDOWN_FORMAT); - } - } - - private List parseRow(String line) { - String stripped = line.trim(); - if (stripped.startsWith("|")) stripped = stripped.substring(1); - if (stripped.endsWith("|")) stripped = stripped.substring(0, stripped.length() - 1); - return Arrays.stream(stripped.split("(? cell.replace("\\|", "|").trim()) - .collect(Collectors.toList()); - } - - private Map buildRowMap(List headers, List cells) { - Map row = new LinkedHashMap<>(); - for (int i = 0; i < headers.size() && i < cells.size(); i++) { - if (!cells.get(i).isBlank()) { - row.put(headers.get(i), cells.get(i)); - } - } - return row; - } -} diff --git a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java index bd9911536..a796fed28 100644 --- a/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/UnivApplyInfoRepository.java @@ -73,6 +73,22 @@ default UnivApplyInfo getUnivApplyInfoById(Long id) { long countByTermIdAndHomeUniversityId(long termId, long homeUniversityId); + @Query(""" + SELECT DISTINCT uai + FROM UnivApplyInfo uai + LEFT JOIN FETCH uai.languageRequirements lr + LEFT JOIN FETCH uai.homeUniversity hu + JOIN FETCH uai.university u + WHERE uai.termId = :termId + AND hu.id = :homeUniversityId + AND u.id = :hostUniversityId + """) + List findAllByTermIdAndHomeUniversityIdAndUniversityId( + @Param("termId") long termId, + @Param("homeUniversityId") long homeUniversityId, + @Param("hostUniversityId") long hostUniversityId + ); + @Query(""" SELECT uai.id FROM UnivApplyInfo uai diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index 74ac8368b..155388a88 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -3,20 +3,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.times; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoCreateRequest; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoLanguageRequirementRequest; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoResponse; import com.example.solidconnection.admin.university.dto.AdminUnivApplyInfoUpdateRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.application.fixture.ApplicationFixture; -import com.example.solidconnection.cache.manager.CustomCacheManager; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.siteuser.domain.SiteUser; @@ -32,9 +27,9 @@ import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.domain.UnivApplyInfoColumn; import com.example.solidconnection.university.fixture.HomeUniversityFixture; +import com.example.solidconnection.university.fixture.LanguageRequirementFixture; import com.example.solidconnection.university.fixture.UnivApplyInfoFixtureBuilder; import com.example.solidconnection.university.fixture.UniversityFixture; -import com.example.solidconnection.university.repository.LanguageRequirementRepository; import com.example.solidconnection.university.repository.LikedUnivApplyInfoRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.Arrays; @@ -45,7 +40,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @TestContainerSpringBootTest @DisplayName("UnivApplyInfo 서비스 테스트") @@ -57,9 +51,6 @@ class AdminUnivApplyInfoServiceTest { @Autowired private UnivApplyInfoRepository univApplyInfoRepository; - @Autowired - private LanguageRequirementRepository languageRequirementRepository; - @Autowired private LikedUnivApplyInfoRepository likedUnivApplyInfoRepository; @@ -75,21 +66,20 @@ class AdminUnivApplyInfoServiceTest { @Autowired private UnivApplyInfoFixtureBuilder univApplyInfoFixtureBuilder; + @Autowired + private LanguageRequirementFixture languageRequirementFixture; + @Autowired private SiteUserFixture siteUserFixture; @Autowired private ApplicationFixture applicationFixture; - @MockitoSpyBean - private CustomCacheManager cacheManager; - private Term term; private HomeUniversity homeUniversity; private HostUniversity hostUniversity; + private HostUniversity otherHostUniversity; - private static final String 괌_대학_한국명 = "괌 대학"; - private static final String 버지니아_대학_한국명 = "버지니아 공과 대학"; private static final long invalidId = 999L; @BeforeEach @@ -97,7 +87,7 @@ void setUp() { term = termFixture.현재_학기("2025-2"); homeUniversity = homeUniversityFixture.인하대학교(); hostUniversity = universityFixture.괌_대학(); - universityFixture.버지니아_공과_대학(); + otherHostUniversity = universityFixture.버지니아_공과_대학(); } @Nested @@ -121,309 +111,6 @@ class 필드_목록을_조회한다 { } - @Nested - class UnivApplyInfo를_임포트한다 { - - @Test - void 모든_행이_정상_저장된다() { - // given - String markdown = String.format(""" - | 대학명 | 인원 | - |--------|------| - | %s | 2 | - | %s | 3 | - """, 괌_대학_한국명, 버지니아_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") - ); - - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertAll( - () -> assertThat(response.successCount()).isEqualTo(2), - () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) - ); - } - - @Test - void 임포트_성공_시_검색과_추천_캐시가_무효화된다() { - // given - String markdown = String.format(""" - | 대학명 | 인원 | - |--------|------| - | %s | 2 | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") - ); - - // when - adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - then(cacheManager).should(times(1)).evictUsingPrefix("univApplyInfoTextSearch"); - then(cacheManager).should(times(1)).evictUsingPrefix("university:recommend:general"); - } - - @Test - void enum_변환_실패시_전체가_실패한다() { - // given - String markdown = String.format(""" - | 대학명 | 파견가능학기 | - |--------|------------| - | %s | 알수없음 | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "파견가능학기", "semesterAvailableForDispatch") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 어학시험_컬럼은_LanguageRequirement로_저장된다() { - // given - String markdown = String.format(""" - | 대학명 | TOEIC | - |--------|-------| - | %s | 800 | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "TOEIC", "TOEIC") - ); - - // when - adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertThat(languageRequirementRepository.findAll()) - .anyMatch(lr -> lr.getLanguageTestType() == LanguageTestType.TOEIC - && "800".equals(lr.getMinScore())); - } - - @Test - void extraInfo_매핑_컬럼은_extraInfo에_저장된다() { - // given - String markdown = String.format(""" - | 대학명 | 특이사항 | - |--------|----------| - | %s | 주의 필요 | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "특이사항", "extraInfo") - ); - - // when - adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); - assertThat(saved.getExtraInfo()).containsEntry("특이사항", "주의 필요"); - } - - @Test - void columnMappings에_없는_컬럼은_extraInfo에_저장된다() { - // given - String markdown = String.format(""" - | 대학명 | 미매핑컬럼 | - |--------|------------| - | %s | 어떤값 | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName") - ); - - // when - adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); - assertThat(saved.getExtraInfo()).containsEntry("미매핑컬럼", "어떤값"); - } - - @Test - void 존재하지_않는_대학명이_있으면_전체가_실패한다() { - // given - String markdown = String.format(""" - | 대학명 | 인원 | - |--------|------| - | %s | 2 | - | 존재하지않는대학교 | 1 | - | %s | 3 | - """, 괌_대학_한국명, 버지니아_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 존재하지_않는_국가코드면_전체가_실패한다() { - // given - String markdown = """ - | 대학명 | 국가코드 | - |--------|----------| - | 새 대학교 | ZZ | - """; - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 대학명이_비어있으면_전체가_실패한다() { - // given - String markdown = """ - | 대학명 | 국가코드 | - |--------|----------| - | | Belgium | - """; - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 구분자_없는_마크다운이면_예외_응답을_반환한다() { - // given - String invalidMarkdown = "| 대학명 |\n| MIT |"; - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), invalidMarkdown, Map.of() - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - } - - @Test - void 존재하지_않는_termId이면_예외_응답을_반환한다() { - // given - String markdown = String.format(""" - | 대학명 | - |--------| - | %s | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - invalidId, homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - } - - @Test - void 존재하지_않는_homeUniversityId이면_예외_응답을_반환한다() { - // given - String markdown = String.format(""" - | 대학명 | - |--------| - | %s | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), invalidId, markdown, - Map.of("대학명", "universityKoreanName") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - } - - @Test - void 선발인원에_정수가_아닌_값이_들어오면_전체가_실패한다() { - // given - String markdown = String.format(""" - | 대학명 | 인원 | - |--------|------| - | %s | School of Business | - """, 괌_대학_한국명); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 길이_제한을_초과하는_값이_들어오면_전체가_실패한다() { - // given - String tooLongValue = "a".repeat(2001); - String markdown = String.format(""" - | 대학명 | 학기요건 | - |--------|----------| - | %s | %s | - """, 괌_대학_한국명, tooLongValue); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of("대학명", "universityKoreanName", "학기요건", "semesterRequirement") - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - - @Test - void 파싱_오류가_있는_행이_있으면_전체가_실패한다() { - // given - String tooLong = "a".repeat(101); - String markdown = String.format(""" - | 대학명 | 인원 | 학기요건 | - |--------|------|----------| - | %s | 정수아님 | %s | - """, 괌_대학_한국명, tooLong); - UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( - term.getId(), homeUniversity.getId(), markdown, - Map.of( - "대학명", "universityKoreanName", - "인원", "studentCapacity", - "학기요건", "semesterRequirement" - ) - ); - - // when & then - assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) - .isInstanceOf(CustomException.class); - assertThat(univApplyInfoRepository.findAll()).isEmpty(); - } - } - @Nested class 지원_정보_생성 { @@ -518,6 +205,49 @@ class 지원_정보_생성 { @Nested class 지원_정보_단건_조회 { + @Test + void termId_homeUniversityId_hostUniversityId로_지원_정보를_조회한다() { + // given + UnivApplyInfo univApplyInfo = univApplyInfoFixtureBuilder.univApplyInfo() + .termId(term.getId()).koreanName("괌대학(A형)") + .university(hostUniversity).homeUniversity(homeUniversity).create(); + languageRequirementFixture.토플_80(univApplyInfo); + univApplyInfoFixtureBuilder.univApplyInfo() + .termId(term.getId()).koreanName("버지니아공과대학") + .university(otherHostUniversity).homeUniversity(homeUniversity).create(); + + // when + List responses = adminUnivApplyInfoService.findUnivApplyInfos( + term.getId(), homeUniversity.getId(), hostUniversity.getId()); + + // then + assertAll( + () -> assertThat(responses).hasSize(1), + () -> assertThat(responses.get(0).id()).isEqualTo(univApplyInfo.getId()), + () -> assertThat(responses.get(0).termId()).isEqualTo(term.getId()), + () -> assertThat(responses.get(0).homeUniversityId()).isEqualTo(homeUniversity.getId()), + () -> assertThat(responses.get(0).hostUniversityId()).isEqualTo(hostUniversity.getId()), + () -> assertThat(responses.get(0).languageRequirements()) + .anyMatch(lr -> lr.languageTestType() == LanguageTestType.TOEFL_IBT + && "80".equals(lr.minScore())) + ); + } + + @Test + void 일치하는_지원_정보가_없으면_빈_목록을_반환한다() { + // given + univApplyInfoFixtureBuilder.univApplyInfo() + .termId(term.getId()).koreanName("버지니아공과대학") + .university(otherHostUniversity).homeUniversity(homeUniversity).create(); + + // when + List responses = adminUnivApplyInfoService.findUnivApplyInfos( + term.getId(), homeUniversity.getId(), hostUniversity.getId()); + + // then + assertThat(responses).isEmpty(); + } + @Test void 존재하는_id로_조회하면_상세_정보를_반환한다() { // given diff --git a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java deleted file mode 100644 index aa46b6481..000000000 --- a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.example.solidconnection.common.util; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.example.solidconnection.common.exception.CustomException; -import com.example.solidconnection.support.TestContainerSpringBootTest; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; - -@TestContainerSpringBootTest -@DisplayName("마크다운 표 파서 테스트") -class MarkdownTableParserTest { - - @Autowired - private MarkdownTableParser parser; - - @Nested - class 정상_파싱 { - - @Test - void 헤더와_데이터_행을_올바르게_파싱한다() { - String markdown = """ - | 대학명 | 인원 | TOEIC | - |--------|------|-------| - | MIT | 2 | 800 | - | 하버드 | 3 | | - """; - - List> rows = parser.parse(markdown); - - assertThat(rows).hasSize(2); - assertThat(rows.get(0)) - .containsEntry("대학명", "MIT") - .containsEntry("인원", "2") - .containsEntry("TOEIC", "800"); - assertThat(rows.get(1)) - .containsEntry("대학명", "하버드") - .containsEntry("인원", "3") - .doesNotContainKey("TOEIC"); - } - - @Test - void 중간_빈_셀이_있어도_이후_컬럼이_올바르게_매핑된다() { - String markdown = """ - | 대학명 | 인원 | TOEIC | - |--------|------|-------| - | MIT | | 800 | - """; - - List> rows = parser.parse(markdown); - - assertThat(rows.get(0)) - .containsEntry("대학명", "MIT") - .doesNotContainKey("인원") - .containsEntry("TOEIC", "800"); - } - - @Test - void 셀_내부의_이스케이프된_파이프는_컬럼_구분자가_아닌_값으로_처리된다() { - String markdown = """ - | 대학명 | 인원 | - |--------|------| - | RWTH Aachen \\| School of Business | 2 | - """; - - List> rows = parser.parse(markdown); - - assertThat(rows.get(0)) - .containsEntry("대학명", "RWTH Aachen | School of Business") - .containsEntry("인원", "2"); - } - - @Test - void 빈_셀은_결과_맵에_포함되지_않는다() { - String markdown = """ - | 대학명 | 인원 | - |--------|------| - | MIT | | - """; - - List> rows = parser.parse(markdown); - - assertThat(rows.get(0)) - .containsKey("대학명") - .doesNotContainKey("인원"); - } - } - - @Nested - class 구조_검증 { - - @Test - void 구분자_행이_없으면_예외를_던진다() { - String markdown = """ - | 대학명 | 인원 | - | MIT | 2 | - """; - - assertThatThrownBy(() -> parser.parse(markdown)) - .isInstanceOf(CustomException.class); - } - - @Test - void 데이터_행이_없으면_예외를_던진다() { - String markdown = """ - | 대학명 | 인원 | - |--------|------| - """; - - assertThatThrownBy(() -> parser.parse(markdown)) - .isInstanceOf(CustomException.class); - } - - @Test - void 헤더와_구분자만_있으면_예외를_던진다() { - String markdown = "| 대학명 |"; - - assertThatThrownBy(() -> parser.parse(markdown)) - .isInstanceOf(CustomException.class); - } - } -} From df9416ec804ced05e28362e57347eb1f5bfc9734 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 19:44:04 +0900 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20Cursor=20=EC=97=90=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=ED=8A=B8=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .claude/.codex와 동일한 hooks, skills, permissions 구성을 .cursor에 맞게 추가한다. Co-authored-by: Cursor --- .cursor/cli.json | 16 + .cursor/hooks.json | 15 + .cursor/hooks/notify.ps1 | 9 + .cursor/hooks/notify.py | 28 + .cursor/hooks/post-edit-check.py | 62 ++ .cursor/permissions.json | 26 + .cursor/skills/load-universities/SKILL.md | 103 +++ .../scripts/ingest_universities.py | 723 ++++++++++++++++++ .../university_ingestion_template.csv | 2 + .cursor/skills/review-pr/SKILL.md | 292 +++++++ .cursor/skills/test/SKILL.md | 248 ++++++ 11 files changed, 1524 insertions(+) create mode 100644 .cursor/cli.json create mode 100644 .cursor/hooks.json create mode 100644 .cursor/hooks/notify.ps1 create mode 100644 .cursor/hooks/notify.py create mode 100644 .cursor/hooks/post-edit-check.py create mode 100644 .cursor/permissions.json create mode 100644 .cursor/skills/load-universities/SKILL.md create mode 100644 .cursor/skills/load-universities/scripts/ingest_universities.py create mode 100644 .cursor/skills/load-universities/templates/university_ingestion_template.csv create mode 100644 .cursor/skills/review-pr/SKILL.md create mode 100644 .cursor/skills/test/SKILL.md diff --git a/.cursor/cli.json b/.cursor/cli.json new file mode 100644 index 000000000..4e2c48135 --- /dev/null +++ b/.cursor/cli.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "permissions": { + "allow": [ + "Mcp(serena:*)", + "Shell(./gradlew)", + "Shell(gh)", + "Shell(git)", + "Shell(cd)", + "WebFetch(javadoc.io)", + "WebFetch(www.baeldung.com)", + "WebFetch(michael-simons.github.io)", + "WebFetch(raw.githubusercontent.com)" + ] + } +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..09471d4a9 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .cursor/hooks/post-edit-check.py || exec python .cursor/hooks/post-edit-check.py" + } + ], + "stop": [ + { + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .cursor/hooks/notify.py || exec python .cursor/hooks/notify.py" + } + ] + } +} diff --git a/.cursor/hooks/notify.ps1 b/.cursor/hooks/notify.ps1 new file mode 100644 index 000000000..bd41213e9 --- /dev/null +++ b/.cursor/hooks/notify.ps1 @@ -0,0 +1,9 @@ +Add-Type -AssemblyName System.Windows.Forms +$n = New-Object System.Windows.Forms.NotifyIcon +$n.Icon = [System.Drawing.SystemIcons]::Information +$n.Visible = $true +$n.BalloonTipTitle = "Cursor" +$n.BalloonTipText = "Awaiting your input" +$n.ShowBalloonTip(5000) +Start-Sleep -Milliseconds 5100 +$n.Dispose() diff --git a/.cursor/hooks/notify.py b/.cursor/hooks/notify.py new file mode 100644 index 000000000..448949e5f --- /dev/null +++ b/.cursor/hooks/notify.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +import os +import platform +import subprocess + +system = platform.system() +script_dir = os.path.dirname(os.path.abspath(__file__)) + +if system == "Darwin": + subprocess.run([ + "osascript", "-e", + 'display notification "Awaiting your input" with title "Cursor"' + ]) +elif system == "Windows": + ps1_path = os.path.join(script_dir, "notify.ps1") + powershell_candidates = [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "powershell", + ] + for ps in powershell_candidates: + try: + subprocess.run( + [ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1_path], + timeout=10, + ) + break + except (FileNotFoundError, subprocess.TimeoutExpired): + continue diff --git a/.cursor/hooks/post-edit-check.py b/.cursor/hooks/post-edit-check.py new file mode 100644 index 000000000..446d0a908 --- /dev/null +++ b/.cursor/hooks/post-edit-check.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +import json +import sys +import re + + +def resolve_file_path(data: dict) -> str: + file_path = data.get("file_path", "") + if file_path: + return file_path + + tool_input = data.get("tool_input", {}) + if isinstance(tool_input, dict): + return tool_input.get("file_path", "") + + return "" + + +data = json.load(sys.stdin) +file_path = resolve_file_path(data) + +if not file_path.endswith(".java") or not file_path: + sys.exit(0) + +try: + with open(file_path) as f: + content = f.read() + lines = content.split("\n") +except Exception: + sys.exit(0) + +warnings = [] + +# 1. 와일드카드 import 체크 +for i, line in enumerate(lines, 1): + if re.match(r"\s*import\s+.*\.\*;", line): + warnings.append(f"L{i}: 와일드카드 import 발견 -> 명시적 import 필요") + +# 2. 파일 끝 줄바꿈 체크 +if content and not content.endswith("\n"): + warnings.append("파일 끝 줄바꿈 누락") + +# 3. Entity 클래스의 @Column 체크 +if "@Entity" in content: + field_pattern = re.compile(r"^\s+private\s+\w+(?:<[^>]+>)?\s+\w+;") + relation_annotations = { + "@Column", "@Id", "@ManyToOne", "@OneToMany", + "@JoinColumn", "@OneToOne", "@ManyToMany", + "@Transient", "@Version", "@Embedded", "@EmbeddedId", + } + for i, line in enumerate(lines): + if field_pattern.match(line): + preceding = "\n".join(lines[max(0, i - 5):i]) + has_annotation = any(ann in preceding for ann in relation_annotations) + if not has_annotation: + warnings.append(f"L{i + 1}: Entity 필드에 @Column 누락 가능성: {line.strip()}") + +if warnings: + print(f"[컨벤션 체크 - {file_path.split('/')[-1]}]", file=sys.stderr) + for w in warnings: + print(f" - {w}", file=sys.stderr) + sys.exit(2) diff --git a/.cursor/permissions.json b/.cursor/permissions.json new file mode 100644 index 000000000..5412f56c0 --- /dev/null +++ b/.cursor/permissions.json @@ -0,0 +1,26 @@ +{ + "mcpAllowlist": [ + "serena:list_dir", + "serena:read_file", + "serena:find_file", + "serena:get_symbols_overview", + "serena:find_symbol", + "serena:think_about_collected_information", + "serena:search_for_pattern", + "serena:replace_content", + "serena:create_text_file" + ], + "terminalAllowlist": [ + "./gradlew test", + "./gradlew compileTestJava", + "./gradlew clean test", + "gh pr view", + "gh pr diff", + "gh pr checks", + "gh run view", + "gh pr commits", + "gh api", + "git fetch", + "cd" + ] +} diff --git a/.cursor/skills/load-universities/SKILL.md b/.cursor/skills/load-universities/SKILL.md new file mode 100644 index 000000000..01b7a026e --- /dev/null +++ b/.cursor/skills/load-universities/SKILL.md @@ -0,0 +1,103 @@ +--- +name: load-universities +description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +--- + +# Load Universities + +Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. + +## Scope + +- Target only the approved dev API: `https://stage.solid-connection.com`. +- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. +- Never use the legacy Markdown import endpoint. +- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. +- Do not target local, prod, or an arbitrary URL. +- Do not mutate anything during preflight. + +## Files + +- Runner: `scripts/ingest_universities.py` +- CSV template: `templates/university_ingestion_template.csv` + +The `.cursor/skills/load-universities`, `.claude/skills/load-universities`, and `.codex/skills/load-universities` copies must stay behaviorally identical. + +## Input Schema + +Required columns: + +- `term_name`: term name in `YYYY-N` format. +- `home_university_name` +- `home_max_choice_count`: required when the home university does not already exist. +- `host_korean_name` +- `host_english_name`: required when the host university does not already exist. +- `host_format_name`: required when the host university does not already exist. +- `country_code`: required when the host university does not already exist. +- `region_code`: required when the host university does not already exist. + +Optional columns: + +- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. +- `home_email_domain` +- `student_capacity` +- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. +- `semester_requirement` +- `details_for_language` +- `gpa_requirement` +- `gpa_requirement_criteria` +- `details_for_accommodation` +- `extra_info`: JSON object, or `key=value;key2=value2`. +- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. +- `homepage_url` +- `english_course_url` +- `accommodation_url` +- `details_for_local` +- `logo_file`: local path or assets-dir relative path for missing host creation. +- `background_file`: local path or assets-dir relative path for missing host creation. + +## Commands + +Preflight only: + +```bash +python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ + --mode preflight \ + --input path/to/universities.csv \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Apply and verify: + +```bash +python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.xlsx \ + --assets-dir path/to/assets \ + --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ + --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" +``` + +Token-based authentication is also supported: + +```bash +python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ + --mode apply \ + --input path/to/universities.csv \ + --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" +``` + +## Workflow + +1. Validate the input file and dev base URL before authenticating. +2. Authenticate with either `--access-token` or admin email/password. +3. Parse every CSV/XLSX row and validate all required fields before mutation. +4. Read existing terms, home universities, and host universities through admin APIs. +5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. +6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. +7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. +8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. +9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. +10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. diff --git a/.cursor/skills/load-universities/scripts/ingest_universities.py b/.cursor/skills/load-universities/scripts/ingest_universities.py new file mode 100644 index 000000000..5c0e92303 --- /dev/null +++ b/.cursor/skills/load-universities/scripts/ingest_universities.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +"""Dev-only Solid Connection university ingestion runner.""" + +from __future__ import annotations + +import argparse +import csv +import json +import mimetypes +import os +import re +import ssl +import sys +import uuid +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen +from xml.etree import ElementTree + + +APPROVED_DEV_BASE_URL = "https://stage.solid-connection.com" +TERM_RE = re.compile(r"^\d{4}-\d$") +ALLOWED_SEMESTERS = { + "ONE_SEMESTER", + "TWO_SEMESTER", + "FOUR_SEMESTER", + "ONE_OR_TWO_SEMESTER", + "ONE_YEAR", + "IRRELEVANT", + "NO_DATA", +} +LANGUAGE_TEST_TYPES = { + "CEFR", + "JLPT", + "DALF", + "DELF", + "DELE", + "DUOLINGO", + "IELTS", + "NEW_HSK", + "TCF", + "TEF", + "TOEFL_IBT", + "TOEFL_ITP", + "TOEIC", + "ETC", +} + + +class IngestionError(Exception): + pass + + +@dataclass(frozen=True) +class ParsedRow: + row_number: int + term_name: str + home_university_name: str + home_max_choice_count: int | None + home_email_domain: str | None + host_korean_name: str + host_english_name: str | None + host_format_name: str | None + country_code: str | None + region_code: str | None + univ_apply_info_id: int | None + student_capacity: int | None + semester_available_for_dispatch: str | None + semester_requirement: str | None + details_for_language: str | None + gpa_requirement: str | None + gpa_requirement_criteria: str | None + details_for_accommodation: str | None + extra_info: dict[str, str] + language_requirements: list[dict[str, str]] + homepage_url: str | None + english_course_url: str | None + accommodation_url: str | None + details_for_local: str | None + logo_file: str | None + background_file: str | None + + +def clean(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def empty_to_none(value: Any) -> str | None: + text = clean(value) + return text or None + + +def parse_int(value: Any, field: str, row_number: int) -> int | None: + text = clean(value) + if not text: + return None + try: + return int(float(text)) + except ValueError as exc: + raise IngestionError(f"row {row_number}: {field} must be an integer") from exc + + +def require(value: Any, field: str, row_number: int) -> str: + text = clean(value) + if not text: + raise IngestionError(f"row {row_number}: missing required field {field}") + return text + + +def parse_extra_info(value: Any, row_number: int) -> dict[str, str]: + text = clean(value) + if not text: + return {} + if text.startswith("{"): + parsed = json.loads(text) + if not isinstance(parsed, dict): + raise IngestionError(f"row {row_number}: extra_info JSON must be an object") + return {str(k): "" if v is None else str(v) for k, v in parsed.items()} + result: dict[str, str] = {} + for part in text.split(";"): + if not part.strip(): + continue + if "=" not in part: + raise IngestionError(f"row {row_number}: extra_info entry must be key=value") + key, item_value = part.split("=", 1) + result[key.strip()] = item_value.strip() + return result + + +def parse_language_requirements(value: Any, row_number: int) -> list[dict[str, str]]: + text = clean(value) + if not text: + return [] + if text.startswith("[") or text.startswith("{"): + parsed = json.loads(text) + if isinstance(parsed, dict): + items = [ + {"languageTestType": str(k), "minScore": str(v)} + for k, v in parsed.items() + ] + elif isinstance(parsed, list): + items = parsed + else: + raise IngestionError(f"row {row_number}: language_requirements JSON must be an object or array") + else: + items = [] + for part in text.split(";"): + if not part.strip(): + continue + if ":" not in part: + raise IngestionError(f"row {row_number}: language requirement must be TYPE:score") + test_type, min_score = part.split(":", 1) + items.append({"languageTestType": test_type.strip(), "minScore": min_score.strip()}) + + normalized = [] + for item in items: + if not isinstance(item, dict): + raise IngestionError(f"row {row_number}: each language requirement must be an object") + test_type = clean(item.get("languageTestType")) + min_score = clean(item.get("minScore")) + if test_type not in LANGUAGE_TEST_TYPES: + raise IngestionError(f"row {row_number}: unsupported language test type {test_type}") + if not min_score: + raise IngestionError(f"row {row_number}: language minScore is required") + normalized.append({"languageTestType": test_type, "minScore": min_score}) + return sorted(normalized, key=lambda lr: (lr["languageTestType"], lr["minScore"])) + + +def read_csv(path: Path) -> list[dict[str, Any]]: + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def xlsx_cell_value(cell: ElementTree.Element, shared_strings: list[str], ns: dict[str, str]) -> str: + cell_type = cell.attrib.get("t") + value_node = cell.find("x:v", ns) + if value_node is None: + inline = cell.find("x:is/x:t", ns) + return inline.text if inline is not None and inline.text is not None else "" + value = value_node.text or "" + if cell_type == "s": + return shared_strings[int(value)] + return value + + +def column_index(cell_ref: str) -> int: + letters = re.sub(r"[^A-Z]", "", cell_ref.upper()) + index = 0 + for char in letters: + index = index * 26 + ord(char) - ord("A") + 1 + return index - 1 + + +def read_xlsx(path: Path) -> list[dict[str, Any]]: + ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} + with zipfile.ZipFile(path) as archive: + shared_strings: list[str] = [] + if "xl/sharedStrings.xml" in archive.namelist(): + root = ElementTree.fromstring(archive.read("xl/sharedStrings.xml")) + for item in root.findall("x:si", ns): + shared_strings.append("".join(t.text or "" for t in item.findall(".//x:t", ns))) + sheet_name = "xl/worksheets/sheet1.xml" + root = ElementTree.fromstring(archive.read(sheet_name)) + rows: list[list[str]] = [] + for row in root.findall(".//x:sheetData/x:row", ns): + values: list[str] = [] + for cell in row.findall("x:c", ns): + idx = column_index(cell.attrib.get("r", "A1")) + while len(values) <= idx: + values.append("") + values[idx] = xlsx_cell_value(cell, shared_strings, ns) + rows.append(values) + if not rows: + return [] + headers = [clean(h) for h in rows[0]] + result = [] + for row in rows[1:]: + if not any(clean(v) for v in row): + continue + result.append({headers[i]: row[i] if i < len(row) else "" for i in range(len(headers))}) + return result + + +def load_rows(path: Path) -> list[dict[str, Any]]: + if not path.exists(): + raise IngestionError(f"input file not found: {path}") + suffix = path.suffix.lower() + if suffix == ".csv": + return read_csv(path) + if suffix == ".xlsx": + return read_xlsx(path) + raise IngestionError("supported input formats are .csv and .xlsx") + + +def parse_rows(raw_rows: list[dict[str, Any]]) -> list[ParsedRow]: + parsed = [] + for idx, raw in enumerate(raw_rows, start=2): + row = {clean(k): v for k, v in raw.items() if clean(k)} + term_name = require(row.get("term_name"), "term_name", idx) + if not TERM_RE.match(term_name): + raise IngestionError(f"row {idx}: term_name must match YYYY-N") + semester = empty_to_none(row.get("semester_available_for_dispatch")) + if semester and semester not in ALLOWED_SEMESTERS: + raise IngestionError(f"row {idx}: unsupported semester_available_for_dispatch {semester}") + parsed.append(ParsedRow( + row_number=idx, + term_name=term_name, + home_university_name=require(row.get("home_university_name"), "home_university_name", idx), + home_max_choice_count=parse_int(row.get("home_max_choice_count"), "home_max_choice_count", idx), + home_email_domain=empty_to_none(row.get("home_email_domain")), + host_korean_name=require(row.get("host_korean_name"), "host_korean_name", idx), + host_english_name=empty_to_none(row.get("host_english_name")), + host_format_name=empty_to_none(row.get("host_format_name")), + country_code=empty_to_none(row.get("country_code")), + region_code=empty_to_none(row.get("region_code")), + univ_apply_info_id=parse_int(row.get("univ_apply_info_id"), "univ_apply_info_id", idx), + student_capacity=parse_int(row.get("student_capacity"), "student_capacity", idx), + semester_available_for_dispatch=semester, + semester_requirement=empty_to_none(row.get("semester_requirement")), + details_for_language=empty_to_none(row.get("details_for_language")), + gpa_requirement=empty_to_none(row.get("gpa_requirement")), + gpa_requirement_criteria=empty_to_none(row.get("gpa_requirement_criteria")), + details_for_accommodation=empty_to_none(row.get("details_for_accommodation")), + extra_info=parse_extra_info(row.get("extra_info"), idx), + language_requirements=parse_language_requirements(row.get("language_requirements"), idx), + homepage_url=empty_to_none(row.get("homepage_url")), + english_course_url=empty_to_none(row.get("english_course_url")), + accommodation_url=empty_to_none(row.get("accommodation_url")), + details_for_local=empty_to_none(row.get("details_for_local")), + logo_file=empty_to_none(row.get("logo_file")), + background_file=empty_to_none(row.get("background_file")), + )) + if not parsed: + raise IngestionError("input contains no data rows") + return parsed + + +class ApiClient: + def __init__(self, base_url: str, access_token: str | None) -> None: + self.base_url = base_url.rstrip("/") + self.access_token = access_token + self.context = ssl.create_default_context() + + def request_json(self, method: str, path: str, body: Any | None = None, query: dict[str, Any] | None = None) -> Any: + url = self.base_url + path + if query: + url += "?" + urlencode({k: v for k, v in query.items() if v is not None}) + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, context=self.context, timeout=30) as response: + payload = response.read().decode("utf-8") + return json.loads(payload) if payload else None + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc + except URLError as exc: + raise IngestionError(f"{method} {path} failed: {exc.reason}") from exc + + def request_multipart(self, path: str, request_part: dict[str, Any], files: dict[str, Path]) -> Any: + boundary = "----solidconnection" + uuid.uuid4().hex + body = bytearray() + + def add_part(name: str, content: bytes, filename: str | None, content_type: str) -> None: + body.extend(f"--{boundary}\r\n".encode()) + disposition = f'Content-Disposition: form-data; name="{name}"' + if filename: + disposition += f'; filename="{filename}"' + body.extend((disposition + "\r\n").encode()) + body.extend(f"Content-Type: {content_type}\r\n\r\n".encode()) + body.extend(content) + body.extend(b"\r\n") + + add_part("request", json.dumps(request_part, ensure_ascii=False).encode("utf-8"), None, "application/json") + for field_name, path_value in files.items(): + content_type = mimetypes.guess_type(path_value.name)[0] or "application/octet-stream" + add_part(field_name, path_value.read_bytes(), path_value.name, content_type) + body.extend(f"--{boundary}--\r\n".encode()) + headers = { + "Accept": "application/json", + "Content-Type": f"multipart/form-data; boundary={boundary}", + } + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + request = Request(self.base_url + path, data=bytes(body), headers=headers, method="POST") + try: + with urlopen(request, context=self.context, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise IngestionError(f"POST {path} failed with HTTP {exc.code}: {detail}") from exc + + def sign_in(self, email: str, password: str) -> None: + response = self.request_json("POST", "/admin/auth/sign-in", {"email": email, "password": password}) + token = response.get("accessToken") if isinstance(response, dict) else None + if not token: + raise IngestionError("admin sign-in response did not contain accessToken") + self.access_token = token + + +def enforce_dev_url(base_url: str) -> str: + normalized = base_url.rstrip("/") + if normalized != APPROVED_DEV_BASE_URL: + raise IngestionError(f"refusing non-dev target: {base_url}") + return normalized + + +def resolve_asset(path_text: str | None, assets_dir: Path | None) -> Path | None: + if not path_text: + return None + path = Path(path_text) + if not path.is_absolute() and assets_dir: + path = assets_dir / path + return path if path.exists() and path.is_file() else None + + +def fetch_all_terms(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/terms")} + + +def fetch_all_home_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + return {item["name"]: item for item in api.request_json("GET", "/admin/home-universities")} + + +def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + by_name: dict[str, dict[str, Any]] = {} + page = 0 + while True: + response = api.request_json("GET", "/admin/host-universities", query={"page": page, "size": 100}) + for item in response.get("content", []): + for name_key in ("koreanName", "englishName", "formatName"): + name = item.get(name_key) + if name: + by_name[name] = item + total_pages = int(response.get("totalPages", 0)) + page += 1 + if page >= total_pages: + break + return by_name + + +def validate_missing_entity_fields(rows: list[ParsedRow], homes: dict[str, Any], hosts: dict[str, Any]) -> None: + for row in rows: + if row.home_university_name not in homes and row.home_max_choice_count is None: + raise IngestionError(f"row {row.row_number}: home_max_choice_count is required for a missing home university") + if row.host_korean_name not in hosts: + for field_name, value in ( + ("host_english_name", row.host_english_name), + ("host_format_name", row.host_format_name), + ("country_code", row.country_code), + ("region_code", row.region_code), + ): + if not value: + raise IngestionError(f"row {row.row_number}: {field_name} is required for a missing host university") + + +def build_plan(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None) -> tuple[dict[str, Any], dict[str, Any]]: + terms = fetch_all_terms(api) + homes = fetch_all_home_universities(api) + hosts = fetch_all_host_universities(api) + validate_missing_entity_fields(rows, homes, hosts) + missing_assets = [] + lookup_failures = [] + will_create_apply_infos = 0 + will_update_apply_infos = 0 + for row in rows: + if row.host_korean_name in hosts: + term = terms.get(row.term_name) + home = homes.get(row.home_university_name) + host = hosts.get(row.host_korean_name) + if term and home and host: + try: + existing = find_existing_apply_info(api, row, term["id"], home["id"], host["id"]) + if existing: + will_update_apply_infos += 1 + else: + will_create_apply_infos += 1 + except IngestionError as exc: + lookup_failures.append({"row": row.row_number, "error": str(exc)}) + else: + will_create_apply_infos += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + missing_assets.append({ + "row": row.row_number, + "host_korean_name": row.host_korean_name, + "host_english_name": row.host_english_name, + "required": { + "logo_file": row.logo_file or f"{slug(row.host_english_name or row.host_korean_name)}-logo", + "background_file": row.background_file or f"{slug(row.host_english_name or row.host_korean_name)}-background", + }, + }) + will_create_apply_infos += 1 + status = "failed" if lookup_failures else "needs-assets" if missing_assets else "preflight-ok" + plan = { + "status": status, + "rows": len(rows), + "missing_assets": missing_assets, + "lookup_failures": lookup_failures, + "will_create_terms": sorted({r.term_name for r in rows if r.term_name not in terms}), + "will_create_home_universities": sorted({r.home_university_name for r in rows if r.home_university_name not in homes}), + "will_create_host_universities": sorted({r.host_korean_name for r in rows if r.host_korean_name not in hosts}), + "will_create_univ_apply_infos": will_create_apply_infos, + "will_update_univ_apply_infos": will_update_apply_infos, + } + indexes = {"terms": terms, "homes": homes, "hosts": hosts} + return plan, indexes + + +def slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def find_existing_apply_info( + api: ApiClient, + row: ParsedRow, + term_id: int, + home_id: int, + host_id: int, +) -> dict[str, Any] | None: + matches = api.request_json("GET", "/admin/univ-apply-infos", query={ + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + }) + if not isinstance(matches, list): + raise IngestionError("natural-key lookup did not return a list") + if len(matches) > 1: + raise IngestionError( + f"duplicate UnivApplyInfo rows for termId={term_id}, homeUniversityId={home_id}, hostUniversityId={host_id}" + ) + if row.univ_apply_info_id is not None: + if matches and int(matches[0]["id"]) != row.univ_apply_info_id: + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match natural-key row {matches[0]['id']}" + ) + if not matches: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{row.univ_apply_info_id}") + if ( + int(fetched.get("termId")) != int(term_id) + or int(fetched.get("homeUniversityId")) != int(home_id) + or int(fetched.get("hostUniversityId")) != int(host_id) + ): + raise IngestionError( + f"univ_apply_info_id {row.univ_apply_info_id} does not match the row's term/home/host natural key" + ) + return fetched + return matches[0] if matches else None + + +def apply_rows(rows: list[ParsedRow], api: ApiClient, assets_dir: Path | None, indexes: dict[str, Any]) -> dict[str, Any]: + counts = { + "terms_created": 0, + "terms_reused": 0, + "home_universities_created": 0, + "home_universities_reused": 0, + "host_universities_created": 0, + "host_universities_reused": 0, + "univ_apply_infos_created": 0, + "univ_apply_infos_updated": 0, + "failed": 0, + } + row_results = [] + terms = indexes["terms"] + homes = indexes["homes"] + hosts = indexes["hosts"] + + for term_name in sorted({r.term_name for r in rows}): + if term_name in terms: + counts["terms_reused"] += 1 + else: + created = api.request_json("POST", "/admin/terms", {"name": term_name}) + terms[term_name] = created + counts["terms_created"] += 1 + + for row in rows: + try: + home = homes.get(row.home_university_name) + if home: + counts["home_universities_reused"] += 1 + else: + home = api.request_json("POST", "/admin/home-universities", { + "name": row.home_university_name, + "maxChoiceCount": row.home_max_choice_count, + "emailDomain": row.home_email_domain, + }) + homes[row.home_university_name] = home + counts["home_universities_created"] += 1 + + host = hosts.get(row.host_korean_name) + if host: + counts["host_universities_reused"] += 1 + else: + logo_path = resolve_asset(row.logo_file, assets_dir) + background_path = resolve_asset(row.background_file, assets_dir) + if not logo_path or not background_path: + raise IngestionError("missing host university image files after preflight") + host = api.request_multipart("/admin/host-universities", { + "koreanName": row.host_korean_name, + "englishName": row.host_english_name, + "formatName": row.host_format_name, + "homepageUrl": row.homepage_url, + "englishCourseUrl": row.english_course_url, + "accommodationUrl": row.accommodation_url, + "detailsForLocal": row.details_for_local, + "countryCode": row.country_code, + "regionCode": row.region_code, + }, {"logoFile": logo_path, "backgroundFile": background_path}) + hosts[row.host_korean_name] = host + counts["host_universities_created"] += 1 + + payload = apply_payload(row) + existing_apply_info = find_existing_apply_info( + api, row, terms[row.term_name]["id"], home["id"], host["id"] + ) + if existing_apply_info is None: + response = api.request_json("POST", "/admin/univ-apply-infos", { + "termId": terms[row.term_name]["id"], + "homeUniversityId": home["id"], + "hostUniversityId": host["id"], + **payload, + }) + counts["univ_apply_infos_created"] += 1 + else: + response = api.request_json("PATCH", f"/admin/univ-apply-infos/{existing_apply_info['id']}", payload) + counts["univ_apply_infos_updated"] += 1 + + verification = verify_row(api, row, response["id"], terms[row.term_name]["id"], home["id"], host["id"]) + row_results.append({"row": row.row_number, "id": response["id"], "status": "verified", "verification": verification}) + except IngestionError as exc: + counts["failed"] += 1 + row_results.append({"row": row.row_number, "status": "failed", "error": str(exc)}) + + status = "verified" if counts["failed"] == 0 else "failed" + return {"status": status, "counts": counts, "rows": row_results} + + +def apply_payload(row: ParsedRow) -> dict[str, Any]: + return { + "studentCapacity": row.student_capacity, + "semesterAvailableForDispatch": row.semester_available_for_dispatch, + "semesterRequirement": row.semester_requirement, + "detailsForLanguage": row.details_for_language, + "gpaRequirement": row.gpa_requirement, + "gpaRequirementCriteria": row.gpa_requirement_criteria, + "detailsForAccommodation": row.details_for_accommodation, + "extraInfo": row.extra_info, + "languageRequirements": row.language_requirements, + } + + +def verify_row(api: ApiClient, row: ParsedRow, apply_info_id: int, term_id: int, home_id: int, host_id: int) -> dict[str, Any]: + fetched = api.request_json("GET", f"/admin/univ-apply-infos/{apply_info_id}") + expected = { + "termId": term_id, + "homeUniversityId": home_id, + "hostUniversityId": host_id, + "koreanName": row.host_korean_name, + **apply_payload(row), + } + mismatches = [] + for key, expected_value in expected.items(): + actual = fetched.get(key) + if key == "languageRequirements": + actual = sorted(actual or [], key=lambda lr: (lr.get("languageTestType"), lr.get("minScore"))) + if actual != expected_value: + mismatches.append({"field": key, "expected": expected_value, "actual": actual}) + if mismatches: + raise IngestionError(f"verification mismatch for univ_apply_info {apply_info_id}: {json.dumps(mismatches, ensure_ascii=False)}") + return {"checked_fields": sorted(expected.keys())} + + +def output(payload: dict[str, Any]) -> None: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + + +def self_test() -> None: + sample = [{ + "term_name": "2026-1", + "home_university_name": "Inha", + "home_max_choice_count": "3", + "host_korean_name": "Sample", + "host_english_name": "Sample University", + "host_format_name": "Sample", + "country_code": "US", + "region_code": "US-CA", + "language_requirements": "TOEFL_IBT:80;IELTS:6.5", + "extra_info": "note=ok", + }] + parsed = parse_rows(sample) + assert parsed[0].language_requirements == [ + {"languageTestType": "IELTS", "minScore": "6.5"}, + {"languageTestType": "TOEFL_IBT", "minScore": "80"}, + ] + assert parsed[0].extra_info == {"note": "ok"} + + class FakeApi: + def request_json(self, method: str, path: str) -> dict[str, Any]: + assert method == "GET" + assert path == "/admin/univ-apply-infos/99" + return { + "termId": 1, + "homeUniversityId": 2, + "hostUniversityId": 3, + "koreanName": "Wrong Korean Name", + "studentCapacity": None, + "semesterAvailableForDispatch": None, + "semesterRequirement": None, + "detailsForLanguage": None, + "gpaRequirement": None, + "gpaRequirementCriteria": None, + "detailsForAccommodation": None, + "extraInfo": {"note": "ok"}, + "languageRequirements": parsed[0].language_requirements, + } + + try: + verify_row(FakeApi(), parsed[0], 99, 1, 2, 3) + except IngestionError as exc: + assert "koreanName" in str(exc) + else: + raise AssertionError("verify_row must fail when fetched koreanName does not match the input host_korean_name") + output({"status": "self-test-ok"}) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Ingest university data into Solid Connection dev.") + parser.add_argument("--mode", choices=["preflight", "apply"], required=False) + parser.add_argument("--input", type=Path) + parser.add_argument("--assets-dir", type=Path) + parser.add_argument("--dev-base-url", default=os.environ.get("SOLID_CONNECT_DEV_API_BASE", APPROVED_DEV_BASE_URL)) + parser.add_argument("--admin-email", default=os.environ.get("SOLID_CONNECT_ADMIN_EMAIL")) + parser.add_argument("--admin-password", default=os.environ.get("SOLID_CONNECT_ADMIN_PASSWORD")) + parser.add_argument("--access-token", default=os.environ.get("SOLID_CONNECT_ADMIN_ACCESS_TOKEN")) + parser.add_argument("--manifest-output", type=Path) + parser.add_argument("--self-test", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + if args.self_test: + self_test() + return 0 + if not args.mode or not args.input: + raise IngestionError("--mode and --input are required unless --self-test is used") + base_url = enforce_dev_url(args.dev_base_url) + raw_rows = load_rows(args.input) + rows = parse_rows(raw_rows) + api = ApiClient(base_url, args.access_token) + if not api.access_token: + if not args.admin_email or not args.admin_password: + raise IngestionError("provide --access-token or both --admin-email and --admin-password") + api.sign_in(args.admin_email, args.admin_password) + plan, indexes = build_plan(rows, api, args.assets_dir) + if args.manifest_output: + args.manifest_output.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8") + if args.mode == "preflight" or plan["status"] in {"needs-assets", "failed"}: + output(plan) + return 0 if plan["status"] in {"preflight-ok", "needs-assets"} else 1 + result = apply_rows(rows, api, args.assets_dir, indexes) + output(result) + return 0 if result["status"] == "verified" else 1 + except (IngestionError, json.JSONDecodeError, KeyError, zipfile.BadZipFile) as exc: + output({"status": "failed", "error": str(exc)}) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.cursor/skills/load-universities/templates/university_ingestion_template.csv b/.cursor/skills/load-universities/templates/university_ingestion_template.csv new file mode 100644 index 000000000..0b4d5a75e --- /dev/null +++ b/.cursor/skills/load-universities/templates/university_ingestion_template.csv @@ -0,0 +1,2 @@ +term_name,home_university_name,home_max_choice_count,home_email_domain,host_korean_name,host_english_name,host_format_name,country_code,region_code,univ_apply_info_id,student_capacity,semester_available_for_dispatch,semester_requirement,details_for_language,gpa_requirement,gpa_requirement_criteria,details_for_accommodation,extra_info,language_requirements,homepage_url,english_course_url,accommodation_url,details_for_local,logo_file,background_file +2026-1,Inha University,3,inha.edu,Example University,Example University,Example University,US,US-CA,,2,ONE_SEMESTER,Spring dispatch only,TOEFL iBT accepted,3.0,4.5 scale,Dormitory available,"{""note"":""sample""}",TOEFL_IBT:80;IELTS:6.5,https://example.edu,https://example.edu/courses,https://example.edu/housing,Local notes,example-university-logo.png,example-university-background.jpg diff --git a/.cursor/skills/review-pr/SKILL.md b/.cursor/skills/review-pr/SKILL.md new file mode 100644 index 000000000..4ef7e6eb2 --- /dev/null +++ b/.cursor/skills/review-pr/SKILL.md @@ -0,0 +1,292 @@ +--- +name: review-pr +description: Pull Request를 체계적으로 리뷰하여 프로젝트 컨벤션 준수 여부와 코드 품질을 검증합니다 +args: (예: /review-pr 666) +--- + +# Pull Request 리뷰 가이드 + +이 skill은 solid-connect-server 프로젝트의 Pull Request를 체계적으로 리뷰합니다. + +## 사용법 + +```bash +/review-pr +``` + +**예제:** + +```bash +/review-pr 666 +``` + +--- + +## 리뷰 프로세스 + +### 1단계: PR 정보 수집 + +GitHub CLI로 PR의 기본 정보와 변경사항을 파악합니다. + +```bash +gh pr view <번호> -R solid-connection/solid-connect-server # PR 기본 정보 조회 +gh pr diff <번호> -R solid-connection/solid-connect-server # 변경된 파일과 diff 확인 +gh pr checks <번호> -R solid-connection/solid-connect-server # CI/CD 상태 확인 +``` + +**수집할 정보:** + +- PR 제목 및 설명 +- 관련 이슈 번호 +- 변경된 파일 목록 +- CI/CD 체크 상태 + +### 2단계: 변경 파일 분석 + +**도구 우선순위:** + +1. **Serena MCP** (Java 코드 분석에 최적화) + - `mcp__serena__get_symbols_overview <파일경로>` - 파일의 클래스/메서드 구조 파악 + - `mcp__serena__find_symbol <심볼명>` - 특정 심볼 검색 + - `mcp__serena__search_for_pattern <패턴>` - 컨벤션 위반 패턴 검색 + +2. **Read/Grep** (보조 분석) + - `Read <파일경로>` - 파일 전체 읽기 + - `Grep --pattern <패턴>` - 패턴 검색 + +### 3단계: 체크리스트 검증 + +아래 체크리스트를 순서대로 확인합니다. + +--- + +## 리뷰 체크리스트 + +각 항목의 상세 컨벤션은 참조 문서를 확인하세요. + +### 1. 아키텍처 및 계층 구조 + +**검증 항목:** + +- 계층형 아키텍처 준수 (Controller → Service → Repository) +- 역계층 참조 금지 +- 순환 의존성 없음 + +👉 **참고:** `CLAUDE.md` - "아키텍처" 섹션 + +--- + +### 2. 네이밍 컨벤션 + +**검증 항목:** + +- API 엔드포인트: kebab-case 사용 (예: `/user-profile`) +- DTO 변환 메서드: 단일 파라미터 `from()`, 다중 파라미터 `of()` +- Request/Response: `XXXRequest`, `XXXResponse` 형식 +- 테스트 메서드: 한국어, `어떤_것을_하면_어떤_결과가_나온다()` 패턴 + +👉 **참고:** `CLAUDE.md` - "네이밍 컨벤션" 섹션 + +--- + +### 3. 코드 스타일 + +**검증 항목:** + +- 와일드카드(`*`) import 금지 +- 클래스 선언 전 빈 줄 존재 +- private 메서드는 호출하는 public 메서드 바로 아래 위치 +- Controller: 모든 파라미터 줄바꿈 필수 +- 일반 메서드: 3개 이상 파라미터 시 줄바꿈 +- 파일 끝 개행 문자 + +**패턴 검색 예제:** + +```bash +mcp__serena__search_for_pattern "import.*\\*" # 와일드카드 import 검색 +``` + +👉 **참고:** `CLAUDE.md` - "코드 스타일" 섹션 + +--- + +### 4. Entity 및 JPA + +**검증 항목:** + +- 모든 필드에 `@Column` 어노테이션 존재 +- `name` 속성으로 컬럼명 명시 +- `nullable` 속성 명시 +- null 불가: 원시 타입 (`int`, `long`, `boolean`) +- nullable: Wrapper 타입 (`Integer`, `Long`, `Boolean`) +- 양방향 연관관계 시 편의 메서드 존재 + +👉 **참고:** `CLAUDE.md` - "데이터베이스 접근" 섹션 + +--- + +### 5. 데이터베이스 마이그레이션 + +**검증 항목:** + +- 스키마 변경 시 Flyway 마이그레이션 파일 추가 +- 파일명 형식: `V{VERSION}__{DESCRIPTION}.sql` +- 위치: `src/main/resources/db/migration/` +- Entity 변경과 마이그레이션 일치 +- 기존 마이그레이션 파일 수정 금지 (새 버전 생성) + +👉 **참고:** `CLAUDE.md` - "데이터베이스 마이그레이션" 섹션 + +--- + +### 6. 테스트 코드 + +**검증 항목:** + +- 새로운 Service/Repository 메서드에 대한 테스트 존재 +- 예외 케이스 테스트 포함 +- `@TestContainerSpringBootTest` 어노테이션 사용 +- `@DisplayName`으로 한글 설명 제공 +- `@Nested`로 기능별 그룹화 +- Given-When-Then 구조 준수 +- Fixture 패턴 사용 (FixtureBuilder + Fixture) + +👉 **참고:** `.cursor/skills/test/SKILL.md` + +--- + +### 7. 커밋 메시지 + +**검증 항목:** + +- `: ` 형식 +- Type: `feat`, `fix`, `refactor`, `test`, `chore`, `docs`, `perf` +- 간결하고 명확한 설명 + +👉 **참고:** `CLAUDE.md` - "Git 커밋 컨벤션" 섹션 + +--- + +### 8. 코드 품질 및 잠재적 이슈 + +**검증 항목:** + +- 비즈니스 로직은 Service 계층에만 +- Controller는 요청/응답 처리만 +- `@Transactional` 적절하게 사용 (읽기 전용: `readOnly = true`) +- CustomException 사용 +- N+1 쿼리 문제 없음 +- 인증/인가 처리 (`@AuthorizedUser`) +- 민감 정보 노출 없음 + +👉 **참고:** `CLAUDE.md` - "아키텍처", "기술 스택 상세" 섹션 + +--- + +## 도구 사용 가이드 + +### Serena MCP (우선 사용) + +```bash +# 파일의 클래스/메서드 구조 파악 +mcp__serena__get_symbols_overview src/main/java/.../MentorService.java + +# 특정 심볼 검색 +mcp__serena__find_symbol "MentorDetailResponse" + +# 컨벤션 위반 패턴 검색 +mcp__serena__search_for_pattern "import.*\\*" +``` + +### GitHub CLI + +```bash +# PR 정보 +gh pr view 666 -R solid-connection/solid-connect-server --json title,body,author,number,url + +# 변경사항 +gh pr diff 666 -R solid-connection/solid-connect-server --patch + +# CI 상태 +gh pr checks 666 -R solid-connection/solid-connect-server +``` + +### 보조 도구 + +```bash +# 파일 읽기 +Read src/main/java/.../MentorService.java + +# 패턴 검색 +Grep --pattern "@Column" --glob "*.java" --path src/main/java/.../domain +``` + +--- + +## 리뷰 결과 출력 형식 + +다음 형식으로 리뷰 결과를 정리하여 제공합니다. + +```markdown +## PR 리뷰 결과: #{번호} - {제목} + +**PR 링크:** {GitHub URL} +**관련 이슈:** #{이슈번호} + +### 📊 PR 정보 요약 + +- **작성자:** {작성자} +- **변경 파일:** {숫자}개 +- **추가 라인:** +{숫자}, **삭제 라인:** -{숫자} +- **CI/CD 상태:** {통과/실패} + +### 주요 변경사항 + +{PR 설명 요약} + +--- + +### ✅ 통과 항목 + +- 아키텍처 계층 구조 준수 +- 네이밍 컨벤션 준수 +- ... + +### ⚠️ 개선 권장 항목 + +- **코드 스타일**: 와일드카드 import 사용 + - 파일: `src/main/java/.../MentorService.java:5` + - 개선: 명시적 import로 변경 + +### ❌ 필수 수정 항목 + +- **Entity**: @Column 어노테이션 누락 + - 파일: `src/main/java/.../domain/Mentor.java:30` + - 수정 방향: 모든 필드에 `@Column` 어노테이션 추가 + +--- + +### 💡 종합 의견 + +{전반적인 리뷰 의견} + +**승인 상태:** ✅ 승인 / ⚠️ 조건부 승인 / ❌ 수정 후 재검토 +``` + +--- + +## 리뷰 시 주의사항 + +1. **컨텍스트 이해 우선**: PR 설명과 관련 이슈를 먼저 읽고 변경의 목적 파악 +2. **Serena MCP 우선 사용**: Java 코드 분석 시 효율적 +3. **건설적 피드백**: 문제점 지적 시 구체적인 개선 방향 제시 +4. **긍정적 피드백**: 잘된 부분도 언급하여 균형 잡힌 리뷰 +5. **우선순위**: 아키텍처 > 네이밍 > 스타일 순으로 중요도 판단 + +--- + +## 참고 자료 + +- **프로젝트 컨벤션**: `CLAUDE.md` - 전체 개발 컨벤션 +- **테스트 가이드**: `.cursor/skills/test/SKILL.md` - 테스트 작성 가이드 +- **개발 컨벤션 위키**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 diff --git a/.cursor/skills/test/SKILL.md b/.cursor/skills/test/SKILL.md new file mode 100644 index 000000000..7fd7e4c58 --- /dev/null +++ b/.cursor/skills/test/SKILL.md @@ -0,0 +1,248 @@ +--- +name: test +description: 테스트 코드를 작성하거나 수정할 때 이 프로젝트의 테스트 컨벤션과 패턴을 참고합니다 +--- + +# 테스트 코드 작성 가이드 + +## 테스트 기본 설정 + +모든 통합 테스트는 `@TestContainerSpringBootTest` 어노테이션을 사용합니다. + +```java +@TestContainerSpringBootTest +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + // 테스트 코드 +} +``` + +**제공 기능:** + +- MySQL, Redis 자동 실행 +- Spring Boot 컨텍스트 로드 +- 테스트 후 자동 DB 초기화 +- JUnit 5 기반 + +## Fixture 패턴 + +테스트 데이터는 Fixture로 생성합니다 (FixtureBuilder + Fixture 패턴). + +**위치:** `src/test/java/com/example/solidconnection/[domain]/fixture/` + +``` +fixture/ +├── [Entity]FixtureBuilder.java # Builder 패턴 구현 +└── [Entity]Fixture.java # 편의 메서드 제공 +``` + +### 예제: ChatRoomFixtureBuilder + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixtureBuilder { + + private final ChatRoomRepository chatRoomRepository; + + private boolean isGroup; + private Long mentoringId; + + public ChatRoomFixtureBuilder chatRoom() { + return new ChatRoomFixtureBuilder(chatRoomRepository); + } + + public ChatRoomFixtureBuilder isGroup(boolean isGroup) { + this.isGroup = isGroup; + return this; + } + + public ChatRoomFixtureBuilder mentoringId(long mentoringId) { + this.mentoringId = mentoringId; + return this; + } + + public ChatRoom create() { + ChatRoom chatRoom = new ChatRoom(mentoringId, isGroup); + return chatRoomRepository.save(chatRoom); // DB 저장 + } +} +``` + +### 예제: ChatRoomFixture + +```java +@TestComponent +@RequiredArgsConstructor +public class ChatRoomFixture { + + private final ChatRoomFixtureBuilder chatRoomFixtureBuilder; + + // 편의 메서드: 기본값으로 생성 + public ChatRoom 채팅방(boolean isGroup) { + return chatRoomFixtureBuilder.chatRoom() + .isGroup(isGroup) + .create(); + } + + public ChatRoom 멘토링_채팅방(long mentoringId) { + return chatRoomFixtureBuilder.chatRoom() + .mentoringId(mentoringId) + .isGroup(false) + .create(); + } +} +``` + +**편의 메서드 작성 팁:** + +- 한국어 메서드명 사용 (가독성) +- 자주 사용되는 기본값 조합만 제공 +- Builder를 조합하여 필요한 데이터 설정 + +### 테스트에서 사용 + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Autowired + private ChatRoomFixture chatRoomFixture; + + @Test + void 채팅방을_생성할_수_있다() { + // 편의 메서드 사용 + ChatRoom room = chatRoomFixture.채팅방(false); + + // Builder 직접 사용 + ChatRoom customRoom = chatRoomFixture.chatRoomFixtureBuilder.chatRoom() + .isGroup(true) + .mentoringId(100L) + .create(); + } +} +``` + +## 테스트 네이밍 컨벤션 + +### 테스트 메서드 네이밍 규칙 + +테스트 메서드명은 **한국어로 명확하게** 작성하며, 다음 패턴을 따릅니다: + +#### 1. 정상 동작 테스트 + +```java +// 패턴: 어떤_것을_하면_어떤_결과가_나온다 +@Test +void 채팅방이_없으면_빈_목록을_반환한다() { ... } + +@Test +void 최신_메시지_순으로_정렬되어_조회한다() { ... } + +@Test +void 참여자는_메시지를_전송할_수_있다() { ... } + +@Test +void 페이징이_정상_작동한다() { ... } +``` + +#### 2. 예외 테스트 + +```java +// 패턴: 어떤_것을_하면_예외_응답을_반환한다 +@Test +void 참여하지_않은_채팅방에_접근하면_예외_응답을_반환한다() { ... } + +@Test +void 존재하지_않는_사용자로_메시지를_전송하면_예외_응답을_반환한다() { ... } + +@Test +void 권한이_없으면_예외_응답을_반환한다() { ... } + +@Test +void 필수_파라미터가_없으면_예외_응답을_반환한다() { ... } +``` + +## BDD 테스트 작성 + +테스트는 Given-When-Then 구조로 작성합니다. + +```java +@Test +@DisplayName("최신 메시지순으로 채팅방 목록을 조회한다") +void 최신_메시지_순으로_조회한다() { + // Given: 테스트 사전 조건 + SiteUser user = siteUserFixture.사용자(); + ChatRoom room1 = chatRoomFixture.채팅방(false); + ChatRoom room2 = chatRoomFixture.채팅방(false); + chatMessageFixture.메시지("오래된 메시지", user.getId(), room1); + chatMessageFixture.메시지("최신 메시지", user.getId(), room2); + + // When: 실제 동작 + ChatRoomListResponse response = chatService.getChatRooms(user.getId()); + + // Then: 결과 검증 + assertAll( + () -> assertThat(response.chatRooms()).hasSize(2), + () -> assertThat(response.chatRooms().get(0).id()).isEqualTo(room2.getId()) + ); +} +``` + +## 테스트 그룹화 (@Nested) + +기능별로 테스트를 그룹화합니다. + +```java +@TestContainerSpringBootTest +class ChatServiceTest { + + @Nested + @DisplayName("채팅방 목록 조회") + class 채팅방_목록을_조회한다 { + + @Test + void 빈_목록을_반환한다() { ... } + + @Test + void 최신_메시지_순으로_조회한다() { ... } + } + + @Nested + @DisplayName("채팅 메시지 전송") + class 채팅_메시지를_전송한다 { + + @BeforeEach + void setUp() { + // 이 그룹에만 적용되는 초기 설정 + } + + @Test + void 참여자는_메시지를_전송할_수_있다() { ... } + } +} +``` + +## 자주 사용하는 Assertion + +```java +// 기본 검증 +assertThat(value).isEqualTo(expected); +assertThat(value).isNotNull(); + +// 컬렉션 +assertThat(list).hasSize(3); +assertThat(list).isEmpty(); +assertThat(list).contains(item); + +// 예외 검증 +assertThatCode(() -> method()) + .isInstanceOf(CustomException.class) + .hasMessage("error message"); + +// 복수 검증 +assertAll( + () -> assertThat(a).isEqualTo(1), + () -> assertThat(b).isEqualTo(2) +); +``` From 824cabb23c56b880f62579986bf4839096568d13 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 19:46:41 +0900 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20Codex=20=ED=9B=85=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=B0=B8=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 훅을 settings.json 단일 설정과 .codex/hooks 경로로 통일하고, skill 문서의 교차 경로 참조를 각 도구 디렉터리 기준으로 수정한다. Co-authored-by: Cursor --- .claude/skills/load-universities/SKILL.md | 2 +- .codex/hooks.json | 15 --------------- .codex/settings.json | 4 ++-- .codex/skills/load-universities/SKILL.md | 2 +- .codex/skills/review-pr/SKILL.md | 4 ++-- 5 files changed, 6 insertions(+), 21 deletions(-) delete mode 100644 .codex/hooks.json diff --git a/.claude/skills/load-universities/SKILL.md b/.claude/skills/load-universities/SKILL.md index e1b1e3bd5..e9c06a03a 100644 --- a/.claude/skills/load-universities/SKILL.md +++ b/.claude/skills/load-universities/SKILL.md @@ -21,7 +21,7 @@ Use this skill when the user asks to ingest or upsert Solid Connection universit - Runner: `scripts/ingest_universities.py` - CSV template: `templates/university_ingestion_template.csv` -The `.claude/skills/load-universities` and `.codex/skills/load-universities` copies must stay behaviorally identical. +The `.claude/skills/load-universities`, `.codex/skills/load-universities`, and `.cursor/skills/load-universities` copies must stay behaviorally identical. ## Input Schema diff --git a/.codex/hooks.json b/.codex/hooks.json deleted file mode 100644 index 74ef76c7d..000000000 --- a/.codex/hooks.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "command -v python3 >/dev/null 2>&1 && exec python3 .codex/hooks/post-edit-check.py || exec python .codex/hooks/post-edit-check.py" - } - ] - } - ] - } -} diff --git a/.codex/settings.json b/.codex/settings.json index f6c5b8ec9..8535078cf 100644 --- a/.codex/settings.json +++ b/.codex/settings.json @@ -9,7 +9,7 @@ "hooks": [ { "type": "command", - "command": "python3 .claude/hooks/notify.py 2>/dev/null || python .claude/hooks/notify.py" + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .codex/hooks/notify.py || exec python .codex/hooks/notify.py" } ] } @@ -20,7 +20,7 @@ "hooks": [ { "type": "command", - "command": "python3 .claude/hooks/post-edit-check.py 2>/dev/null || python .claude/hooks/post-edit-check.py" + "command": "command -v python3 >/dev/null 2>&1 && exec python3 .codex/hooks/post-edit-check.py || exec python .codex/hooks/post-edit-check.py" } ] } diff --git a/.codex/skills/load-universities/SKILL.md b/.codex/skills/load-universities/SKILL.md index 6dde1b127..871684924 100644 --- a/.codex/skills/load-universities/SKILL.md +++ b/.codex/skills/load-universities/SKILL.md @@ -21,7 +21,7 @@ Use this skill when the user asks to ingest or upsert Solid Connection universit - Runner: `scripts/ingest_universities.py` - CSV template: `templates/university_ingestion_template.csv` -The `.claude/skills/load-universities` and `.codex/skills/load-universities` copies must stay behaviorally identical. +The `.claude/skills/load-universities`, `.codex/skills/load-universities`, and `.cursor/skills/load-universities` copies must stay behaviorally identical. ## Input Schema diff --git a/.codex/skills/review-pr/SKILL.md b/.codex/skills/review-pr/SKILL.md index 8a50da5ed..464e84464 100644 --- a/.codex/skills/review-pr/SKILL.md +++ b/.codex/skills/review-pr/SKILL.md @@ -151,7 +151,7 @@ mcp__serena__search_for_pattern "import.*\\*" # 와일드카드 import 검색 - Given-When-Then 구조 준수 - Fixture 패턴 사용 (FixtureBuilder + Fixture) -👉 **참고:** `.claude/skills/test/SKILL.md` +👉 **참고:** `.codex/skills/test/SKILL.md` --- @@ -288,5 +288,5 @@ Grep --pattern "@Column" --glob "*.java" --path src/main/java/.../domain ## 참고 자료 - **프로젝트 컨벤션**: `CLAUDE.md` - 전체 개발 컨벤션 -- **테스트 가이드**: `.claude/skills/test/SKILL.md` - 테스트 작성 가이드 +- **테스트 가이드**: `.codex/skills/test/SKILL.md` - 테스트 작성 가이드 - **개발 컨벤션 위키**: https://github.com/solid-connection/solid-connect-server/wiki/개발-컨벤션-정리 From 4ae0484d6fdb4f753beed9941f07325acbdab1f0 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sun, 2 Aug 2026 19:56:10 +0900 Subject: [PATCH 6/7] feat: prompt university ingestion credentials --- .claude/skills/load-universities/SKILL.md | 12 +++++------- .../scripts/ingest_universities.py | 15 ++++++++++++++- .codex/skills/load-universities/SKILL.md | 12 +++++------- .../scripts/ingest_universities.py | 15 ++++++++++++++- .cursor/skills/load-universities/SKILL.md | 12 +++++------- .../scripts/ingest_universities.py | 15 ++++++++++++++- 6 files changed, 57 insertions(+), 24 deletions(-) diff --git a/.claude/skills/load-universities/SKILL.md b/.claude/skills/load-universities/SKILL.md index e9c06a03a..92f80f086 100644 --- a/.claude/skills/load-universities/SKILL.md +++ b/.claude/skills/load-universities/SKILL.md @@ -64,9 +64,7 @@ Preflight only: python3 .claude/skills/load-universities/scripts/ingest_universities.py \ --mode preflight \ --input path/to/universities.csv \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` Apply and verify: @@ -75,11 +73,11 @@ Apply and verify: python3 .claude/skills/load-universities/scripts/ingest_universities.py \ --mode apply \ --input path/to/universities.xlsx \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` +When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. + Token-based authentication is also supported: ```bash @@ -92,7 +90,7 @@ python3 .claude/skills/load-universities/scripts/ingest_universities.py \ ## Workflow 1. Validate the input file and dev base URL before authenticating. -2. Authenticate with either `--access-token` or admin email/password. +2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. 3. Parse every CSV/XLSX row and validate all required fields before mutation. 4. Read existing terms, home universities, and host universities through admin APIs. 5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. diff --git a/.claude/skills/load-universities/scripts/ingest_universities.py b/.claude/skills/load-universities/scripts/ingest_universities.py index 5c0e92303..60ec6953e 100644 --- a/.claude/skills/load-universities/scripts/ingest_universities.py +++ b/.claude/skills/load-universities/scripts/ingest_universities.py @@ -5,6 +5,7 @@ import argparse import csv +import getpass import json import mimetypes import os @@ -626,6 +627,18 @@ def output(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) +def prompt_for_admin_credentials() -> tuple[str, str]: + if not sys.stdin.isatty(): + raise IngestionError( + "provide --access-token or both --admin-email and --admin-password when stdin is not interactive" + ) + email = input("Stage admin email: ").strip() + password = getpass.getpass("Stage admin password: ") + if not email or not password: + raise IngestionError("stage admin email and password are required") + return email, password + + def self_test() -> None: sample = [{ "term_name": "2026-1", @@ -703,7 +716,7 @@ def main(argv: list[str]) -> int: api = ApiClient(base_url, args.access_token) if not api.access_token: if not args.admin_email or not args.admin_password: - raise IngestionError("provide --access-token or both --admin-email and --admin-password") + args.admin_email, args.admin_password = prompt_for_admin_credentials() api.sign_in(args.admin_email, args.admin_password) plan, indexes = build_plan(rows, api, args.assets_dir) if args.manifest_output: diff --git a/.codex/skills/load-universities/SKILL.md b/.codex/skills/load-universities/SKILL.md index 871684924..fbd68b4a2 100644 --- a/.codex/skills/load-universities/SKILL.md +++ b/.codex/skills/load-universities/SKILL.md @@ -64,9 +64,7 @@ Preflight only: python3 .codex/skills/load-universities/scripts/ingest_universities.py \ --mode preflight \ --input path/to/universities.csv \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` Apply and verify: @@ -75,9 +73,7 @@ Apply and verify: python3 .codex/skills/load-universities/scripts/ingest_universities.py \ --mode apply \ --input path/to/universities.xlsx \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` Token-based authentication is also supported: @@ -89,10 +85,12 @@ python3 .codex/skills/load-universities/scripts/ingest_universities.py \ --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" ``` +When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. + ## Workflow 1. Validate the input file and dev base URL before authenticating. -2. Authenticate with either `--access-token` or admin email/password. +2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. 3. Parse every CSV/XLSX row and validate all required fields before mutation. 4. Read existing terms, home universities, and host universities through admin APIs. 5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. diff --git a/.codex/skills/load-universities/scripts/ingest_universities.py b/.codex/skills/load-universities/scripts/ingest_universities.py index 5c0e92303..60ec6953e 100644 --- a/.codex/skills/load-universities/scripts/ingest_universities.py +++ b/.codex/skills/load-universities/scripts/ingest_universities.py @@ -5,6 +5,7 @@ import argparse import csv +import getpass import json import mimetypes import os @@ -626,6 +627,18 @@ def output(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) +def prompt_for_admin_credentials() -> tuple[str, str]: + if not sys.stdin.isatty(): + raise IngestionError( + "provide --access-token or both --admin-email and --admin-password when stdin is not interactive" + ) + email = input("Stage admin email: ").strip() + password = getpass.getpass("Stage admin password: ") + if not email or not password: + raise IngestionError("stage admin email and password are required") + return email, password + + def self_test() -> None: sample = [{ "term_name": "2026-1", @@ -703,7 +716,7 @@ def main(argv: list[str]) -> int: api = ApiClient(base_url, args.access_token) if not api.access_token: if not args.admin_email or not args.admin_password: - raise IngestionError("provide --access-token or both --admin-email and --admin-password") + args.admin_email, args.admin_password = prompt_for_admin_credentials() api.sign_in(args.admin_email, args.admin_password) plan, indexes = build_plan(rows, api, args.assets_dir) if args.manifest_output: diff --git a/.cursor/skills/load-universities/SKILL.md b/.cursor/skills/load-universities/SKILL.md index 01b7a026e..236299d6e 100644 --- a/.cursor/skills/load-universities/SKILL.md +++ b/.cursor/skills/load-universities/SKILL.md @@ -64,9 +64,7 @@ Preflight only: python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ --mode preflight \ --input path/to/universities.csv \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` Apply and verify: @@ -75,11 +73,11 @@ Apply and verify: python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ --mode apply \ --input path/to/universities.xlsx \ - --assets-dir path/to/assets \ - --admin-email "$SOLID_CONNECT_ADMIN_EMAIL" \ - --admin-password "$SOLID_CONNECT_ADMIN_PASSWORD" + --assets-dir path/to/assets ``` +When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. + Token-based authentication is also supported: ```bash @@ -92,7 +90,7 @@ python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ ## Workflow 1. Validate the input file and dev base URL before authenticating. -2. Authenticate with either `--access-token` or admin email/password. +2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. 3. Parse every CSV/XLSX row and validate all required fields before mutation. 4. Read existing terms, home universities, and host universities through admin APIs. 5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. diff --git a/.cursor/skills/load-universities/scripts/ingest_universities.py b/.cursor/skills/load-universities/scripts/ingest_universities.py index 5c0e92303..60ec6953e 100644 --- a/.cursor/skills/load-universities/scripts/ingest_universities.py +++ b/.cursor/skills/load-universities/scripts/ingest_universities.py @@ -5,6 +5,7 @@ import argparse import csv +import getpass import json import mimetypes import os @@ -626,6 +627,18 @@ def output(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) +def prompt_for_admin_credentials() -> tuple[str, str]: + if not sys.stdin.isatty(): + raise IngestionError( + "provide --access-token or both --admin-email and --admin-password when stdin is not interactive" + ) + email = input("Stage admin email: ").strip() + password = getpass.getpass("Stage admin password: ") + if not email or not password: + raise IngestionError("stage admin email and password are required") + return email, password + + def self_test() -> None: sample = [{ "term_name": "2026-1", @@ -703,7 +716,7 @@ def main(argv: list[str]) -> int: api = ApiClient(base_url, args.access_token) if not api.access_token: if not args.admin_email or not args.admin_password: - raise IngestionError("provide --access-token or both --admin-email and --admin-password") + args.admin_email, args.admin_password = prompt_for_admin_credentials() api.sign_in(args.admin_email, args.admin_password) plan, indexes = build_plan(rows, api, args.assets_dir) if args.manifest_output: From eca87e065e8d6e0d8cde9a47a67ce8857cd0a803 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 3 Aug 2026 00:59:09 +0900 Subject: [PATCH 7/7] docs: strengthen university import skill --- .claude/skills/load-universities/SKILL.md | 139 ++++++++------------- .codex/skills/load-universities/SKILL.md | 141 ++++++++-------------- .codex/skills/review-pr/SKILL.md | 2 +- .codex/skills/test/SKILL.md | 2 + .cursor/skills/load-universities/SKILL.md | 139 ++++++++------------- 5 files changed, 152 insertions(+), 271 deletions(-) diff --git a/.claude/skills/load-universities/SKILL.md b/.claude/skills/load-universities/SKILL.md index 92f80f086..77b3111df 100644 --- a/.claude/skills/load-universities/SKILL.md +++ b/.claude/skills/load-universities/SKILL.md @@ -1,101 +1,60 @@ --- name: load-universities -description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +description: Safely prepare and apply an AI-reviewed university import from an arbitrary source XLSX file. --- # Load Universities -Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. +Use this skill when asked to load university exchange information from a university-provided XLSX file. Source workbooks are not required to have a stable layout: sheets, header rows, merged cells, column names, and notice rows may differ for every upload. -## Scope +## Scope and Safety -- Target only the approved dev API: `https://stage.solid-connection.com`. -- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. -- Never use the legacy Markdown import endpoint. -- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. -- Do not target local, prod, or an arbitrary URL. -- Do not mutate anything during preflight. - -## Files - -- Runner: `scripts/ingest_universities.py` -- CSV template: `templates/university_ingestion_template.csv` - -The `.claude/skills/load-universities`, `.codex/skills/load-universities`, and `.cursor/skills/load-universities` copies must stay behaviorally identical. - -## Input Schema - -Required columns: - -- `term_name`: term name in `YYYY-N` format. -- `home_university_name` -- `home_max_choice_count`: required when the home university does not already exist. -- `host_korean_name` -- `host_english_name`: required when the host university does not already exist. -- `host_format_name`: required when the host university does not already exist. -- `country_code`: required when the host university does not already exist. -- `region_code`: required when the host university does not already exist. - -Optional columns: - -- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. -- `home_email_domain` -- `student_capacity` -- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. -- `semester_requirement` -- `details_for_language` -- `gpa_requirement` -- `gpa_requirement_criteria` -- `details_for_accommodation` -- `extra_info`: JSON object, or `key=value;key2=value2`. -- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. -- `homepage_url` -- `english_course_url` -- `accommodation_url` -- `details_for_local` -- `logo_file`: local path or assets-dir relative path for missing host creation. -- `background_file`: local path or assets-dir relative path for missing host creation. - -## Commands - -Preflight only: - -```bash -python3 .claude/skills/load-universities/scripts/ingest_universities.py \ - --mode preflight \ - --input path/to/universities.csv \ - --assets-dir path/to/assets -``` - -Apply and verify: - -```bash -python3 .claude/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.xlsx \ - --assets-dir path/to/assets -``` - -When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. - -Token-based authentication is also supported: - -```bash -python3 .claude/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.csv \ - --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" -``` +- Before any workbook inspection, authentication, or API request, ask the operator to choose the target environment: `local` or `stage`. Do not infer or reuse an environment from an earlier import. +- Target only the selected environment through `/admin/**` APIs: `local` uses `http://localhost:8080`; `stage` uses `https://stage.solid-connection.com`. Never target prod or an arbitrary URL. +- Do not create or maintain parsers, mappings, templates, or configuration keyed by a home university, term, workbook layout, sheet, or header. +- Treat the workbook as evidence, not as an API payload. The agent interprets it for this one import and prepares a transient canonical payload only after review. +- Never mutate data while extraction questions are unresolved. Require an explicit user confirmation to apply the entire file. +- Before any admin API lookup, establish authentication. After the operator has selected the environment, if no authenticated admin session or token is available, ask for the administrator email and password as the next focused question; do not proceed with an unauthenticated API probe instead. +- Use supplied credentials only to sign in to the selected environment's `/admin/auth/sign-in` endpoint and retain the resulting access token only in process memory for this import. Never put credentials or access/refresh tokens in a command line, file, environment file, payload, report, browser page, or final answer. +- If sign-in fails, the selected endpoint redirects away from `/admin/**`, or authenticated read-only requests cannot be made, stop and report the exact access blocker. Do not substitute another host or infer an API base URL. +- For a missing `HostUniversity`, image candidates may come only from the university's official website or Wikipedia. Show the image and source URL. Any other or uncertain source is a blocking question. +- Keep the source workbook unchanged. Do not overwrite it or ask the operator to convert it into a template. ## Workflow -1. Validate the input file and dev base URL before authenticating. -2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. -3. Parse every CSV/XLSX row and validate all required fields before mutation. -4. Read existing terms, home universities, and host universities through admin APIs. -5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. -6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. -7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. -8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. -9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. -10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. +1. Ask the operator to select `local` or `stage`. Use only that environment's prescribed base URL for the rest of the import. +2. Establish an authenticated admin session for the selected environment. If credentials are unavailable, ask for the administrator email and password before inspecting the workbook or calling any admin endpoint. Sign in once, keep the access token only in memory, and use it only for this import. +3. Inspect every workbook sheet before drawing conclusions. Identify data tables, header rows, merged-cell values, footnotes, excluded rows, and the source locations supporting each extracted value. +4. Infer the proposed `term_name` and `home_university_name` from the workbook/file context. Extract candidate `HostUniversity` and `UnivApplyInfo` records from relevant rows only. +5. Resolve existing terms, home universities, host universities, and application rows through authenticated, read-only admin API calls. Do not mutate yet. +6. For every missing, ambiguous, conflicting, or low-confidence value, ask one focused question. Examples include a university identity match, country/region code, capacity meaning, language requirement interpretation, and image source. Do not guess. +7. Find required logo/background candidates for new host universities from the allowed sources. If no suitable candidate exists, ask for an image; do not apply the file. +8. Present one file-level review containing: + - source file identity and every relevant sheet/cell or range; + - extracted values and unresolved-question status; + - existing-match decision and planned create/update/delete action per university; + - image preview/source URL for each new host university; + - counts for the previous scope, extracted records, creates, updates, deletes, and blockers. +9. Treat the file as the complete snapshot for its `home university + term`. Existing `UnivApplyInfo` records in that scope absent from the approved review are deletion candidates. Show them before asking for confirmation. +10. Only after the user explicitly approves the whole review, create the transient canonical payload required by `scripts/ingest_universities.py`, run its preflight, then apply and re-fetch verification. Delete only the reviewed stale rows and report every outcome. If the existing API cannot delete a referenced record, report the blocking record and failed snapshot; do not conceal partial results. +11. Report the previous, extracted, created, updated, deleted, skipped, and failed counts with row-level verification results and source references. + +## Existing Runner + +`scripts/ingest_universities.py` is a dev-only final upsert helper for the transient canonical payload. It is not an arbitrary-workbook parser and must never be given a raw source XLSX unless that file already happens to use its canonical schema. + +Use `--mode preflight` before `--mode apply`. Provide local logo/background files only after their sources have been reviewed. The runner's structured-row verification remains mandatory, but it does not replace the file-level review above. + +## Canonical Payload Fields + +The agent may create a temporary payload for the runner with `term_name`, `home_university_name`, `home_max_choice_count`, `host_korean_name`, `host_english_name`, `host_format_name`, `country_code`, `region_code`, capacities, requirements, URLs, and image paths. This is an internal handoff only; it must not be requested from the user as a prerequisite and should be removed from temporary storage after the run when safe. + +## Stop Conditions + +- Stop before any workbook inspection, authentication, or API access until the operator selects `local` or `stage`. +- Stop before workbook extraction or API access when administrator credentials have not been provided and no authenticated session is available; ask for the administrator email and password. +- Stop after a failed sign-in or inaccessible selected endpoint; never try a different host as a workaround. +- Stop before mutation when a question, identity match, image source, or required field remains unresolved. +- Stop when an image candidate is not from an official university website or Wikipedia. +- Stop and show the full review when the user has not explicitly approved the entire file. +- Treat a failed post-apply verification or a failed reviewed deletion as a failed import and report the exact affected records. diff --git a/.codex/skills/load-universities/SKILL.md b/.codex/skills/load-universities/SKILL.md index fbd68b4a2..bf47f2b69 100644 --- a/.codex/skills/load-universities/SKILL.md +++ b/.codex/skills/load-universities/SKILL.md @@ -1,101 +1,62 @@ --- name: load-universities -description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +description: Safely prepare and apply an AI-reviewed university import from an arbitrary source XLSX file. --- # Load Universities -Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. +Use this skill when asked to load university exchange information from a university-provided XLSX file. Source workbooks are not required to have a stable layout: sheets, header rows, merged cells, column names, and notice rows may differ for every upload. -## Scope +## Scope and Safety -- Target only the approved dev API: `https://stage.solid-connection.com`. -- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. -- Never use the legacy Markdown import endpoint. -- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. -- Do not target local, prod, or an arbitrary URL. -- Do not mutate anything during preflight. - -## Files - -- Runner: `scripts/ingest_universities.py` -- CSV template: `templates/university_ingestion_template.csv` - -The `.claude/skills/load-universities`, `.codex/skills/load-universities`, and `.cursor/skills/load-universities` copies must stay behaviorally identical. - -## Input Schema - -Required columns: - -- `term_name`: term name in `YYYY-N` format. -- `home_university_name` -- `home_max_choice_count`: required when the home university does not already exist. -- `host_korean_name` -- `host_english_name`: required when the host university does not already exist. -- `host_format_name`: required when the host university does not already exist. -- `country_code`: required when the host university does not already exist. -- `region_code`: required when the host university does not already exist. - -Optional columns: - -- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. -- `home_email_domain` -- `student_capacity` -- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. -- `semester_requirement` -- `details_for_language` -- `gpa_requirement` -- `gpa_requirement_criteria` -- `details_for_accommodation` -- `extra_info`: JSON object, or `key=value;key2=value2`. -- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. -- `homepage_url` -- `english_course_url` -- `accommodation_url` -- `details_for_local` -- `logo_file`: local path or assets-dir relative path for missing host creation. -- `background_file`: local path or assets-dir relative path for missing host creation. - -## Commands - -Preflight only: - -```bash -python3 .codex/skills/load-universities/scripts/ingest_universities.py \ - --mode preflight \ - --input path/to/universities.csv \ - --assets-dir path/to/assets -``` - -Apply and verify: - -```bash -python3 .codex/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.xlsx \ - --assets-dir path/to/assets -``` - -Token-based authentication is also supported: - -```bash -python3 .codex/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.csv \ - --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" -``` - -When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. +- Before any workbook inspection, authentication, or API request, ask the operator to choose the target environment: `local` or `stage`. Do not infer or reuse an environment from an earlier import. +- Target only the selected environment through `/admin/**` APIs: `local` uses `http://localhost:8080`; `stage` uses `https://api.stage.solid-connection.com`. Never target prod or an arbitrary URL. +- Do not create or maintain parsers, mappings, templates, or configuration keyed by a home university, term, workbook layout, sheet, or header. +- Treat the workbook as evidence, not as an API payload. The agent interprets it for this one import and prepares a transient canonical payload only after review. +- Never mutate data while extraction questions are unresolved. Require an explicit user confirmation to apply the entire file. +- Before any admin API lookup, establish authentication. After the operator has selected the environment, if no authenticated admin session or token is available, ask for the administrator email and password as the next focused question; do not proceed with an unauthenticated API probe instead. +- Resolve the prescribed host before signing in. If the ordinary execution environment cannot resolve or connect to it, retry only the same prescribed host through an approved unrestricted-network path; do not replace it with a guessed IP address or alternate hostname. +- Use supplied credentials only to sign in to the selected environment's `/admin/auth/sign-in` endpoint and retain the resulting access token only in process memory for this import. Submit credentials through a no-echo input channel. Never put credentials or access/refresh tokens in a command line, file, environment file, payload, terminal output, report, browser page, or final answer. +- If sign-in fails, capture the HTTP status and `Location` header without exposing credentials. Follow a redirect only when it remains on the prescribed host and under `/admin/**`; otherwise report the exact access blocker and continue with the configured stage API host. Do not ask for confirmation for safe, reversible import steps. +- For a missing `HostUniversity`, image candidates may come only from the university's official website or Wikipedia. Show the image and source URL. Any other or uncertain source is a blocking question. +- Keep the source workbook unchanged. Do not overwrite it or ask the operator to convert it into a template. ## Workflow -1. Validate the input file and dev base URL before authenticating. -2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. -3. Parse every CSV/XLSX row and validate all required fields before mutation. -4. Read existing terms, home universities, and host universities through admin APIs. -5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. -6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. -7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. -8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. -9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. -10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. +1. Ask the operator to select `local` or `stage`. Use only that environment's prescribed base URL for the rest of the import. For stage, use `https://api.stage.solid-connection.com`. +2. Resolve the prescribed host. If resolution or connection fails in a sandboxed environment, retry the identical host through the approved unrestricted-network path before reporting an access blocker. +3. Establish an authenticated admin session for the selected environment. If credentials are unavailable, ask for the administrator email and password before inspecting the workbook or calling any admin endpoint. Sign in once through a no-echo input channel, keep the access token only in memory, and use it only for this import. +4. Inspect every workbook sheet before drawing conclusions. Identify data tables, header rows, merged-cell values, footnotes, excluded rows, and the source locations supporting each extracted value. +5. Infer the proposed `term_name` and `home_university_name` from the workbook/file context. Extract candidate `HostUniversity` and `UnivApplyInfo` records from relevant rows only. +6. Resolve existing terms, home universities, host universities, and application rows through authenticated, read-only admin API calls. Do not mutate yet. +7. For every missing, ambiguous, conflicting, or low-confidence value, ask one focused question. Examples include a university identity match, country/region code, capacity meaning, language requirement interpretation, and image source. Do not guess. +8. Find required logo/background candidates for new host universities from the allowed sources. If no suitable candidate exists, ask for an image; do not apply the file. +9. Present one file-level review containing: + - source file identity and every relevant sheet/cell or range; + - extracted values and unresolved-question status; + - existing-match decision and planned create/update/delete action per university; + - image preview/source URL for each new host university; + - counts for the previous scope, extracted records, creates, updates, deletes, and blockers. +10. Treat the file as the complete snapshot for its `home university + term`. Existing `UnivApplyInfo` records in that scope absent from the approved review are deletion candidates. Show them before asking for confirmation. +11. Only after the user explicitly approves the whole review, create the transient canonical payload required by `scripts/ingest_universities.py`, run its preflight, then apply and re-fetch verification. Delete only the reviewed stale rows and report every outcome. If the existing API cannot delete a referenced record, report the blocking record and failed snapshot; do not conceal partial results. +12. Report the previous, extracted, created, updated, deleted, skipped, and failed counts with row-level verification results and source references. + +## Existing Runner + +`scripts/ingest_universities.py` is a dev-only final upsert helper for the transient canonical payload. It is not an arbitrary-workbook parser and must never be given a raw source XLSX unless that file already happens to use its canonical schema. + +Use `--mode preflight` before `--mode apply`. Provide local logo/background files only after their sources have been reviewed. The runner's structured-row verification remains mandatory, but it does not replace the file-level review above. + +## Canonical Payload Fields + +The agent may create a temporary payload for the runner with `term_name`, `home_university_name`, `home_max_choice_count`, `host_korean_name`, `host_english_name`, `host_format_name`, `country_code`, `region_code`, capacities, requirements, URLs, and image paths. This is an internal handoff only; it must not be requested from the user as a prerequisite and should be removed from temporary storage after the run when safe. + +## Stop Conditions + +- Stop before any workbook inspection, authentication, or API access until the operator selects `local` or `stage`. +- Stop before workbook extraction or API access when administrator credentials have not been provided and no authenticated session is available; ask for the administrator email and password. +- Stop after a failed sign-in, a redirect outside the prescribed host or `/admin/**`, or an inaccessible selected endpoint; never try a different host as a workaround. +- Stop before mutation when a question, identity match, image source, or required field remains unresolved. +- Stop when an image candidate is not from an official university website or Wikipedia. +- Stop and show the full review when the user has not explicitly approved the entire file. +- Treat a failed post-apply verification or a failed reviewed deletion as a failed import and report the exact affected records. diff --git a/.codex/skills/review-pr/SKILL.md b/.codex/skills/review-pr/SKILL.md index 464e84464..d7bd02ce3 100644 --- a/.codex/skills/review-pr/SKILL.md +++ b/.codex/skills/review-pr/SKILL.md @@ -6,7 +6,7 @@ args: (예: /review-pr 666) # Pull Request 리뷰 가이드 -이 skill은 solid-connect-server 프로젝트의 Pull Request를 체계적으로 리뷰합니다. +이 skill은 solid-connect-server 프로젝트의 Pull Request를 체계적으로 리뷰합니다. 안전하고 읽기 전용 또는 되돌릴 수 있는 단계는 확인을 묻지 않고 진행하며, 운영상 예외는 최종 리뷰에 보고합니다. ## 사용법 diff --git a/.codex/skills/test/SKILL.md b/.codex/skills/test/SKILL.md index 7fd7e4c58..96a078fd7 100644 --- a/.codex/skills/test/SKILL.md +++ b/.codex/skills/test/SKILL.md @@ -5,6 +5,8 @@ description: 테스트 코드를 작성하거나 수정할 때 이 프로젝트 # 테스트 코드 작성 가이드 +안전하고 읽기 전용 또는 되돌릴 수 있는 테스트 단계는 확인을 묻지 않고 진행하며, 운영상 예외는 검증 결과와 함께 보고합니다. + ## 테스트 기본 설정 모든 통합 테스트는 `@TestContainerSpringBootTest` 어노테이션을 사용합니다. diff --git a/.cursor/skills/load-universities/SKILL.md b/.cursor/skills/load-universities/SKILL.md index 236299d6e..77b3111df 100644 --- a/.cursor/skills/load-universities/SKILL.md +++ b/.cursor/skills/load-universities/SKILL.md @@ -1,101 +1,60 @@ --- name: load-universities -description: Load structured university application data into the Solid Connection dev environment through admin APIs, with read-only preflight and row-level verification. +description: Safely prepare and apply an AI-reviewed university import from an arbitrary source XLSX file. --- # Load Universities -Use this skill when the user asks to ingest or upsert Solid Connection university data from a CSV or XLSX file. +Use this skill when asked to load university exchange information from a university-provided XLSX file. Source workbooks are not required to have a stable layout: sheets, header rows, merged cells, column names, and notice rows may differ for every upload. -## Scope +## Scope and Safety -- Target only the approved dev API: `https://stage.solid-connection.com`. -- Use `/admin/**` APIs for authentication, entity reads, creation, update, and verification. -- Never use the legacy Markdown import endpoint. -- Never write credentials to repository files, reports, manifests, shell history examples, or final answers. -- Do not target local, prod, or an arbitrary URL. -- Do not mutate anything during preflight. - -## Files - -- Runner: `scripts/ingest_universities.py` -- CSV template: `templates/university_ingestion_template.csv` - -The `.cursor/skills/load-universities`, `.claude/skills/load-universities`, and `.codex/skills/load-universities` copies must stay behaviorally identical. - -## Input Schema - -Required columns: - -- `term_name`: term name in `YYYY-N` format. -- `home_university_name` -- `home_max_choice_count`: required when the home university does not already exist. -- `host_korean_name` -- `host_english_name`: required when the host university does not already exist. -- `host_format_name`: required when the host university does not already exist. -- `country_code`: required when the host university does not already exist. -- `region_code`: required when the host university does not already exist. - -Optional columns: - -- `univ_apply_info_id`: optional safety check. The runner primarily resolves existing rows by `termId + homeUniversityId + hostUniversityId`; when this ID is present it must match the resolved row. -- `home_email_domain` -- `student_capacity` -- `semester_available_for_dispatch`: enum such as `ONE_SEMESTER`, `TWO_SEMESTER`, `ONE_OR_TWO_SEMESTER`, `ONE_YEAR`, `IRRELEVANT`, `NO_DATA`. -- `semester_requirement` -- `details_for_language` -- `gpa_requirement` -- `gpa_requirement_criteria` -- `details_for_accommodation` -- `extra_info`: JSON object, or `key=value;key2=value2`. -- `language_requirements`: JSON array like `[{"languageTestType":"TOEFL_IBT","minScore":"80"}]`, JSON object like `{"TOEFL_IBT":"80"}`, or `TOEFL_IBT:80;IELTS:6.5`. -- `homepage_url` -- `english_course_url` -- `accommodation_url` -- `details_for_local` -- `logo_file`: local path or assets-dir relative path for missing host creation. -- `background_file`: local path or assets-dir relative path for missing host creation. - -## Commands - -Preflight only: - -```bash -python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ - --mode preflight \ - --input path/to/universities.csv \ - --assets-dir path/to/assets -``` - -Apply and verify: - -```bash -python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.xlsx \ - --assets-dir path/to/assets -``` - -When no access token or complete email/password pair is supplied, the runner prompts for the stage admin email and a hidden password in an interactive terminal. It never writes either value to files or reports. Non-interactive runs must use an access token or explicit credentials. - -Token-based authentication is also supported: - -```bash -python3 .cursor/skills/load-universities/scripts/ingest_universities.py \ - --mode apply \ - --input path/to/universities.csv \ - --access-token "$SOLID_CONNECT_ADMIN_ACCESS_TOKEN" -``` +- Before any workbook inspection, authentication, or API request, ask the operator to choose the target environment: `local` or `stage`. Do not infer or reuse an environment from an earlier import. +- Target only the selected environment through `/admin/**` APIs: `local` uses `http://localhost:8080`; `stage` uses `https://stage.solid-connection.com`. Never target prod or an arbitrary URL. +- Do not create or maintain parsers, mappings, templates, or configuration keyed by a home university, term, workbook layout, sheet, or header. +- Treat the workbook as evidence, not as an API payload. The agent interprets it for this one import and prepares a transient canonical payload only after review. +- Never mutate data while extraction questions are unresolved. Require an explicit user confirmation to apply the entire file. +- Before any admin API lookup, establish authentication. After the operator has selected the environment, if no authenticated admin session or token is available, ask for the administrator email and password as the next focused question; do not proceed with an unauthenticated API probe instead. +- Use supplied credentials only to sign in to the selected environment's `/admin/auth/sign-in` endpoint and retain the resulting access token only in process memory for this import. Never put credentials or access/refresh tokens in a command line, file, environment file, payload, report, browser page, or final answer. +- If sign-in fails, the selected endpoint redirects away from `/admin/**`, or authenticated read-only requests cannot be made, stop and report the exact access blocker. Do not substitute another host or infer an API base URL. +- For a missing `HostUniversity`, image candidates may come only from the university's official website or Wikipedia. Show the image and source URL. Any other or uncertain source is a blocking question. +- Keep the source workbook unchanged. Do not overwrite it or ask the operator to convert it into a template. ## Workflow -1. Validate the input file and dev base URL before authenticating. -2. Authenticate with an access token, explicit credentials, or the interactive terminal prompt. -3. Parse every CSV/XLSX row and validate all required fields before mutation. -4. Read existing terms, home universities, and host universities through admin APIs. -5. If a host university is missing and either required image is absent, stop with JSON status `needs-assets`. This is a successful preflight result and performs zero mutations. -6. In `apply` mode, create missing terms, home universities, and host universities in dependency order. Existing terms, home universities, and host universities are reused and not modified. -7. Resolve existing `UnivApplyInfo` records with `GET /admin/univ-apply-infos?termId=&homeUniversityId=&hostUniversityId=`. -8. Fail on duplicate natural-key matches. Create absent `UnivApplyInfo` records and update existing records, including language requirements. -9. Re-fetch every touched `UnivApplyInfo` with `GET /admin/univ-apply-infos/{id}` and compare relation IDs, host Korean name, core fields, `extraInfo`, and language requirements. -10. Treat any mismatch as failure. Report created/reused/updated/failed counts and row-level failures. +1. Ask the operator to select `local` or `stage`. Use only that environment's prescribed base URL for the rest of the import. +2. Establish an authenticated admin session for the selected environment. If credentials are unavailable, ask for the administrator email and password before inspecting the workbook or calling any admin endpoint. Sign in once, keep the access token only in memory, and use it only for this import. +3. Inspect every workbook sheet before drawing conclusions. Identify data tables, header rows, merged-cell values, footnotes, excluded rows, and the source locations supporting each extracted value. +4. Infer the proposed `term_name` and `home_university_name` from the workbook/file context. Extract candidate `HostUniversity` and `UnivApplyInfo` records from relevant rows only. +5. Resolve existing terms, home universities, host universities, and application rows through authenticated, read-only admin API calls. Do not mutate yet. +6. For every missing, ambiguous, conflicting, or low-confidence value, ask one focused question. Examples include a university identity match, country/region code, capacity meaning, language requirement interpretation, and image source. Do not guess. +7. Find required logo/background candidates for new host universities from the allowed sources. If no suitable candidate exists, ask for an image; do not apply the file. +8. Present one file-level review containing: + - source file identity and every relevant sheet/cell or range; + - extracted values and unresolved-question status; + - existing-match decision and planned create/update/delete action per university; + - image preview/source URL for each new host university; + - counts for the previous scope, extracted records, creates, updates, deletes, and blockers. +9. Treat the file as the complete snapshot for its `home university + term`. Existing `UnivApplyInfo` records in that scope absent from the approved review are deletion candidates. Show them before asking for confirmation. +10. Only after the user explicitly approves the whole review, create the transient canonical payload required by `scripts/ingest_universities.py`, run its preflight, then apply and re-fetch verification. Delete only the reviewed stale rows and report every outcome. If the existing API cannot delete a referenced record, report the blocking record and failed snapshot; do not conceal partial results. +11. Report the previous, extracted, created, updated, deleted, skipped, and failed counts with row-level verification results and source references. + +## Existing Runner + +`scripts/ingest_universities.py` is a dev-only final upsert helper for the transient canonical payload. It is not an arbitrary-workbook parser and must never be given a raw source XLSX unless that file already happens to use its canonical schema. + +Use `--mode preflight` before `--mode apply`. Provide local logo/background files only after their sources have been reviewed. The runner's structured-row verification remains mandatory, but it does not replace the file-level review above. + +## Canonical Payload Fields + +The agent may create a temporary payload for the runner with `term_name`, `home_university_name`, `home_max_choice_count`, `host_korean_name`, `host_english_name`, `host_format_name`, `country_code`, `region_code`, capacities, requirements, URLs, and image paths. This is an internal handoff only; it must not be requested from the user as a prerequisite and should be removed from temporary storage after the run when safe. + +## Stop Conditions + +- Stop before any workbook inspection, authentication, or API access until the operator selects `local` or `stage`. +- Stop before workbook extraction or API access when administrator credentials have not been provided and no authenticated session is available; ask for the administrator email and password. +- Stop after a failed sign-in or inaccessible selected endpoint; never try a different host as a workaround. +- Stop before mutation when a question, identity match, image source, or required field remains unresolved. +- Stop when an image candidate is not from an official university website or Wikipedia. +- Stop and show the full review when the user has not explicitly approved the entire file. +- Treat a failed post-apply verification or a failed reviewed deletion as a failed import and report the exact affected records.