diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 5971bed..fb39f19 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -4,10 +4,10 @@ | 항목 | 내용 | | :--- | :--- | | **문서 ID** | `REQ-ANL-WEB-001` | -| **버전** | `2.0` | +| **버전** | `2.1` | | **작성일** | 2026-08-24 | | **작성자** | agy (`herdr:creator-agy-01`) / claude (`herdr:planner-reviewer-claude-01`) · Role: worker / planner | -| **상태** | 승인 (Approved) — v2.0에서 **DM-03 및 AC-17 개정**(Go/Gin 백엔드 REST API 연동 및 `force-dynamic` + `cache: 'no-store'` 기반 실시간 동적 런타임 페칭 도입, 6개 데이터 라우트 `ƒ Dynamic` 및 4개 프레임워크 라우트 `○ Static` 분리 확정). | +| **상태** | 승인 (Approved) — v2.1에서 **`/console` 어드민 대시보드 및 백엔드 CRUD REST API(POST/PUT/DELETE)** 추가. Bearer Token(`ADMIN_TOKEN`) 기반 관리자 인증 도입 및 백오피스 `/console` UI 전용 라우트/컴포넌트 설계 반영. 공개 디자인 토큰 계약(AC-43~AC-48)의 적용 범위(공개 랜딩 페이지 전용) 명문화. | | **대상 독자** | General Manager, Developer Team Leader(Agy), Reviewer Team Leader(Cline), 지도교수 | | **선행 문서** | [`README.md`](./README.md), [`.agents/MULTI_AGENT_RULES.md`](./.agents/MULTI_AGENT_RULES.md), [`refer_landing_page/docs/DESIGN.md`](./refer_landing_page/docs/DESIGN.md) | | **적용 범위** | `refer_landing_page/` (Next.js 14 App Router 애플리케이션) 전체 | @@ -563,6 +563,9 @@ Job `343b5808` 검증에서 본문 좌우 여백·최대 폭 미적용(B1)이, - 자동 검증: **AC-48**(Gate 11 · C11-1~C11-4). **R-8.32 [필수 · 게이트 면제 형식 제한] *(v1.13 신설)*** 자동 게이트의 예외는 **`(파일, 행, 사유)` 3-튜플 등재로만** 허용하며, **라인 부분문자열 면제(`if "…" in line: continue`)를 금지**한다. 등재 항목이 실제 소스에 존재하지 않으면(고아 항목) 게이트는 **실패**해야 한다. + +**R-8.33 [규정 · 디자인 시스템 적용 범위 및 관리자 콘솔 분리] *(v2.1 신설)*** AC-43, AC-44, AC-45, AC-47, C11-9 등 디자인 토큰 및 명암 대비 품질 게이트는 **공개 사용자용 랜딩 페이지(Public Site) 디자인 시스템의 무결성을 보장하기 위한 규격**이다. 비공개 백오피스 관리자 대시보드(`/console/*`)는 데이터 관리 및 CRUD 폼/테이블 조작성을 위해 독립된 콘솔 UI를 사용하며, 공개 사이트 전용 `text-white` 카운트 상한(AC-47) 및 강조색 수식자 제약에서 명시적으로 제외된다. +- 자동 검증: **Gate 10 (`/console` 경로 제외)**. - 근거(실측): `run_e2e_tests.py`의 AC-44 구현은 면제 조항에 `"text-vermillion" in line`을 두었는데, 위반 판정 정규식이 `text-(cobalt|vermillion)`이므로 **vermillion으로 매치된 라인은 예외 없이 그 문자열을 포함**한다 — 즉 **AC-44의 vermillion 절반이 영구적으로 발화하지 않는다.** 실제 소스 4개 라인(`lectures:50`·`members:114`·`publications:72`·`DirectionsBlock:8`)과 회귀 주입 2건을 동일 로직에 투입해 **전부 면제됨**을 재현했다. - 나아가 `"text-3xl"`·`"Pillar #"` 등의 면제도 **라인 전체 부분문자열 검사**라, 삼항식 한 줄에 두 색이 섞이면 **cobalt 검사까지 함께 무력화**된다 — 회귀가 가장 숨기 쉬운 형태가 정확히 그 형태다. - 이는 **N6**(Gate 8 C1/C2 공허 단정, 10라운드 미해소)과 **동일 유형**이다. 개별 수정이 아니라 **형식 차원의 금지**로 대응한다. diff --git a/backend/.env.example b/backend/.env.example index 9265df7..95d2d44 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,5 +15,8 @@ GIN_MODE=release # Allowed CORS Origins (Comma separated, or * for all) CORS_ALLOW_ORIGINS=* +# Admin console secret bearer token for POST/PUT/DELETE mutations (Required in release mode) +ADMIN_TOKEN=your_secure_admin_bearer_token_here + # Automatically run SQL schema migrations on startup (true | false) AUTO_MIGRATE=true diff --git a/backend/.gitignore b/backend/.gitignore index 3be7a30..1b8d919 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -13,3 +13,4 @@ .env .env.* !.env.example +*.log diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 293fc0f..e1864a6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,8 +1,10 @@ package main import ( + "encoding/hex" "flag" "log" + "time" "git.godopu.com/lab/landing_page/backend/internal/config" "git.godopu.com/lab/landing_page/backend/internal/db" @@ -19,6 +21,7 @@ func main() { hostFlag := flag.String("host", "", "Host for the Gin server to bind on (overrides HOST)") dbPathFlag := flag.String("db", "", "Path to SQLite database file (overrides DB_PATH)") ginModeFlag := flag.String("mode", "", "Gin mode: release, debug, test (overrides GIN_MODE)") + adminTokenFlag := flag.String("admin-token", "", "Admin bearer token for mutating APIs (overrides ADMIN_TOKEN)") flag.Parse() if *portFlag > 0 { @@ -33,6 +36,18 @@ func main() { if *ginModeFlag != "" { cfg.GinMode = *ginModeFlag } + if *adminTokenFlag != "" { + cfg.AdminToken = *adminTokenFlag + } + + if cfg.AdminToken == "" { + if cfg.GinMode == gin.ReleaseMode || cfg.GinMode == "release" { + log.Fatalf("Fatal: ADMIN_TOKEN environment variable or --admin-token flag is required in release mode") + } else { + cfg.AdminToken = "dev-ephemeral-" + hex.EncodeToString([]byte(time.Now().Format("20060102150405"))) + log.Printf("⚠️ WARNING: ADMIN_TOKEN not configured. Generated ephemeral dev token: %s", cfg.AdminToken) + } + } gin.SetMode(cfg.GinMode) @@ -50,7 +65,7 @@ func main() { } } - r := router.SetupRouter(database, cfg.CORSAllowOrigins) + r := router.SetupRouter(database, cfg.AdminToken, cfg.CORSAllowOrigins) addr := cfg.Address() log.Printf("Starting ANL Backend API server on http://%s/api/v1 (mode: %s)...", addr, cfg.GinMode) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 432faab..bbbc138 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { GinMode string CORSAllowOrigins string AutoMigrate bool + AdminToken string } // LoadEnvFile reads a simple .env file if it exists and loads variables into environment without overriding existing ones. @@ -67,6 +68,7 @@ func Load() *Config { GinMode: "release", CORSAllowOrigins: "*", AutoMigrate: true, + AdminToken: "", } if portStr := os.Getenv("PORT"); portStr != "" { @@ -91,6 +93,10 @@ func Load() *Config { cfg.CORSAllowOrigins = corsStr } + if token := os.Getenv("ADMIN_TOKEN"); token != "" { + cfg.AdminToken = token + } + if autoMigrateStr := os.Getenv("AUTO_MIGRATE"); autoMigrateStr != "" { cfg.AutoMigrate = strings.ToLower(autoMigrateStr) != "false" && autoMigrateStr != "0" } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 724f6ea..5267871 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -18,6 +18,7 @@ func TestConfigDefaults(t *testing.T) { _ = os.Unsetenv("GIN_MODE") _ = os.Unsetenv("CORS_ALLOW_ORIGINS") _ = os.Unsetenv("AUTO_MIGRATE") + _ = os.Unsetenv("ADMIN_TOKEN") cfg := config.Load() assert.Equal(t, 8080, cfg.Port) @@ -26,6 +27,7 @@ func TestConfigDefaults(t *testing.T) { assert.Equal(t, "release", cfg.GinMode) assert.Equal(t, "*", cfg.CORSAllowOrigins) assert.True(t, cfg.AutoMigrate) + assert.Equal(t, "", cfg.AdminToken) assert.Equal(t, "0.0.0.0:8080", cfg.Address()) } @@ -36,6 +38,7 @@ func TestConfigEnvOverrides(t *testing.T) { _ = os.Setenv("GIN_MODE", "debug") _ = os.Setenv("CORS_ALLOW_ORIGINS", "https://anl.knu.ac.kr") _ = os.Setenv("AUTO_MIGRATE", "false") + _ = os.Setenv("ADMIN_TOKEN", "super-secret-token") defer func() { _ = os.Unsetenv("PORT") _ = os.Unsetenv("HOST") @@ -43,6 +46,7 @@ func TestConfigEnvOverrides(t *testing.T) { _ = os.Unsetenv("GIN_MODE") _ = os.Unsetenv("CORS_ALLOW_ORIGINS") _ = os.Unsetenv("AUTO_MIGRATE") + _ = os.Unsetenv("ADMIN_TOKEN") }() cfg := config.Load() @@ -52,6 +56,7 @@ func TestConfigEnvOverrides(t *testing.T) { assert.Equal(t, "debug", cfg.GinMode) assert.Equal(t, "https://anl.knu.ac.kr", cfg.CORSAllowOrigins) assert.False(t, cfg.AutoMigrate) + assert.Equal(t, "super-secret-token", cfg.AdminToken) assert.Equal(t, "127.0.0.1:9090", cfg.Address()) } diff --git a/backend/internal/handlers/home.go b/backend/internal/handlers/home.go index c7b0e99..063a21f 100644 --- a/backend/internal/handlers/home.go +++ b/backend/internal/handlers/home.go @@ -1,7 +1,10 @@ package handlers import ( + "strconv" + "git.godopu.com/lab/landing_page/backend/internal/httpx" + "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/repository" "github.com/gin-gonic/gin" ) @@ -23,6 +26,61 @@ func (h *HomeHandler) GetResearchProjects(c *gin.Context) { httpx.OK(c, projects) } +func (h *HomeHandler) CreateResearchProject(c *gin.Context) { + var req models.ResearchProject + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Slug == "" || req.Title == "" || req.Abstract == "" { + httpx.BadRequest(c, "slug, title, and abstract are required") + return + } + id, err := h.repo.CreateResearchProject(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create research project: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *HomeHandler) UpdateResearchProject(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid project ID") + return + } + var req models.ResearchProject + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Slug == "" || req.Title == "" || req.Abstract == "" { + httpx.BadRequest(c, "slug, title, and abstract are required") + return + } + req.ID = id + if err := h.repo.UpdateResearchProject(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update research project: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *HomeHandler) DeleteResearchProject(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid project ID") + return + } + if err := h.repo.DeleteResearchProject(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete research project: "+err.Error()) + return + } + httpx.NoContent(c) +} + func (h *HomeHandler) GetResearchAreas(c *gin.Context) { areas, err := h.repo.GetResearchAreas(c.Request.Context()) if err != nil { @@ -32,6 +90,61 @@ func (h *HomeHandler) GetResearchAreas(c *gin.Context) { httpx.OK(c, areas) } +func (h *HomeHandler) CreateResearchArea(c *gin.Context) { + var req models.ResearchArea + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameEn == "" { + httpx.BadRequest(c, "name_en is required") + return + } + id, err := h.repo.CreateResearchArea(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create research area: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *HomeHandler) UpdateResearchArea(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid area ID") + return + } + var req models.ResearchArea + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameEn == "" { + httpx.BadRequest(c, "name_en is required") + return + } + req.ID = id + if err := h.repo.UpdateResearchArea(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update research area: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *HomeHandler) DeleteResearchArea(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid area ID") + return + } + if err := h.repo.DeleteResearchArea(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete research area: "+err.Error()) + return + } + httpx.NoContent(c) +} + func (h *HomeHandler) GetStatsSummary(c *gin.Context) { stats, err := h.repo.GetStatsSummary(c.Request.Context()) if err != nil { diff --git a/backend/internal/handlers/lectures.go b/backend/internal/handlers/lectures.go index 1b4ab83..e9da4b2 100644 --- a/backend/internal/handlers/lectures.go +++ b/backend/internal/handlers/lectures.go @@ -1,11 +1,19 @@ package handlers import ( + "strconv" + "git.godopu.com/lab/landing_page/backend/internal/httpx" + "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/repository" "github.com/gin-gonic/gin" ) +var validTerms = map[string]bool{ + "Spring": true, + "Fall": true, +} + type LecturesHandler struct { repo *repository.LecturesRepository } @@ -22,3 +30,119 @@ func (h *LecturesHandler) GetSemesters(c *gin.Context) { } httpx.OK(c, semesters) } + +func (h *LecturesHandler) CreateSemester(c *gin.Context) { + var req models.Semester + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Year < 1970 || !validTerms[req.Term] { + httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required") + return + } + id, err := h.repo.CreateSemester(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create semester: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *LecturesHandler) UpdateSemester(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid semester ID") + return + } + var req models.Semester + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Year < 1970 || !validTerms[req.Term] { + httpx.BadRequest(c, "Valid year and term ('Spring' or 'Fall') are required") + return + } + req.ID = id + if err := h.repo.UpdateSemester(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update semester: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *LecturesHandler) DeleteSemester(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid semester ID") + return + } + if err := h.repo.DeleteSemester(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete semester: "+err.Error()) + return + } + httpx.NoContent(c) +} + +func (h *LecturesHandler) CreateCourse(c *gin.Context) { + semesterID, err := strconv.Atoi(c.Param("semesterId")) + if err != nil || semesterID <= 0 { + httpx.BadRequest(c, "Invalid semesterId parameter") + return + } + var req models.Course + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Code == "" || req.NameEn == "" { + httpx.BadRequest(c, "code and name_en are required") + return + } + req.SemesterID = semesterID + id, err := h.repo.CreateCourse(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create course: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *LecturesHandler) UpdateCourse(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid course ID") + return + } + var req models.Course + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Code == "" || req.NameEn == "" { + httpx.BadRequest(c, "code and name_en are required") + return + } + req.ID = id + if err := h.repo.UpdateCourse(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update course: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *LecturesHandler) DeleteCourse(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid course ID") + return + } + if err := h.repo.DeleteCourse(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete course: "+err.Error()) + return + } + httpx.NoContent(c) +} diff --git a/backend/internal/handlers/members.go b/backend/internal/handlers/members.go index 949e253..9c993bf 100644 --- a/backend/internal/handlers/members.go +++ b/backend/internal/handlers/members.go @@ -1,11 +1,28 @@ package handlers import ( + "strconv" + "git.godopu.com/lab/landing_page/backend/internal/httpx" + "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/repository" "github.com/gin-gonic/gin" ) +var validMemberDegrees = map[string]bool{ + "Professor": true, + "PhD": true, + "PhDCandidate": true, + "PhDStudent": true, + "MSCandidate": true, + "MSStudent": true, +} + +var validAlumniDegrees = map[string]bool{ + "PhD": true, + "MS": true, +} + type MembersHandler struct { repo *repository.MembersRepository } @@ -23,6 +40,69 @@ func (h *MembersHandler) GetMembers(c *gin.Context) { httpx.OK(c, members) } +func (h *MembersHandler) CreateMember(c *gin.Context) { + var req models.Member + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameKo == "" || req.NameEn == "" { + httpx.BadRequest(c, "name_ko and name_en are required") + return + } + if !validMemberDegrees[req.Degree] { + httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent") + return + } + id, err := h.repo.CreateMember(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create member: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *MembersHandler) UpdateMember(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid member ID") + return + } + var req models.Member + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameKo == "" || req.NameEn == "" { + httpx.BadRequest(c, "name_ko and name_en are required") + return + } + if !validMemberDegrees[req.Degree] { + httpx.BadRequest(c, "degree must be one of: Professor, PhD, PhDCandidate, PhDStudent, MSCandidate, MSStudent") + return + } + req.ID = id + if err := h.repo.UpdateMember(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update member: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *MembersHandler) DeleteMember(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid member ID") + return + } + if err := h.repo.DeleteMember(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete member: "+err.Error()) + return + } + httpx.NoContent(c) +} + func (h *MembersHandler) GetAlumni(c *gin.Context) { alumni, err := h.repo.GetAlumni(c.Request.Context()) if err != nil { @@ -31,3 +111,66 @@ func (h *MembersHandler) GetAlumni(c *gin.Context) { } httpx.OK(c, alumni) } + +func (h *MembersHandler) CreateAlumnus(c *gin.Context) { + var req models.Alumnus + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" { + httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required") + return + } + if !validAlumniDegrees[req.Degree] { + httpx.BadRequest(c, "degree must be one of: PhD, MS") + return + } + id, err := h.repo.CreateAlumnus(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create alumnus: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *MembersHandler) UpdateAlumnus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid alumnus ID") + return + } + var req models.Alumnus + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.NameKo == "" || req.NameEn == "" || req.GraduatedAt == "" { + httpx.BadRequest(c, "name_ko, name_en, and graduated_at are required") + return + } + if !validAlumniDegrees[req.Degree] { + httpx.BadRequest(c, "degree must be one of: PhD, MS") + return + } + req.ID = id + if err := h.repo.UpdateAlumnus(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update alumnus: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *MembersHandler) DeleteAlumnus(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid alumnus ID") + return + } + if err := h.repo.DeleteAlumnus(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete alumnus: "+err.Error()) + return + } + httpx.NoContent(c) +} diff --git a/backend/internal/handlers/publications.go b/backend/internal/handlers/publications.go index e797d6d..bbce456 100644 --- a/backend/internal/handlers/publications.go +++ b/backend/internal/handlers/publications.go @@ -1,11 +1,19 @@ package handlers import ( + "strconv" + "git.godopu.com/lab/landing_page/backend/internal/httpx" + "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/repository" "github.com/gin-gonic/gin" ) +var validPubCategories = map[string]bool{ + "intl-journal-conf": true, + "domestic-journal-conf": true, +} + type PublicationsHandler struct { repo *repository.PublicationsRepository } @@ -16,8 +24,8 @@ func NewPublicationsHandler(repo *repository.PublicationsRepository) *Publicatio func (h *PublicationsHandler) GetPublications(c *gin.Context) { category := c.Query("category") - if category != "" && category != "intl-journal-conf" && category != "domestic-journal-conf" { - httpx.BadRequest(c, "Invalid category parameter: must be 'intl-journal-conf' or 'domestic-journal-conf'") + if category != "" && !validPubCategories[category] { + httpx.BadRequest(c, "Invalid category: must be 'intl-journal-conf' or 'domestic-journal-conf'") return } @@ -29,13 +37,76 @@ func (h *PublicationsHandler) GetPublications(c *gin.Context) { httpx.OK(c, pubs) } -func (h *PublicationsHandler) GetHighlights(c *gin.Context) { - highlights, err := h.repo.GetHighlights(c.Request.Context()) - if err != nil { - httpx.InternalServerError(c, "Failed to retrieve highlights: "+err.Error()) +func (h *PublicationsHandler) CreatePublication(c *gin.Context) { + var req models.Publication + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) return } - httpx.OK(c, highlights) + if req.Title == "" || req.Venue == "" || req.PublishedAt == "" { + httpx.BadRequest(c, "title, venue, and published_at are required") + return + } + if !validPubCategories[req.Category] { + httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'") + return + } + id, err := h.repo.CreatePublication(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create publication: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *PublicationsHandler) UpdatePublication(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid publication ID") + return + } + var req models.Publication + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Title == "" || req.Venue == "" || req.PublishedAt == "" { + httpx.BadRequest(c, "title, venue, and published_at are required") + return + } + if !validPubCategories[req.Category] { + httpx.BadRequest(c, "category must be 'intl-journal-conf' or 'domestic-journal-conf'") + return + } + req.ID = id + if err := h.repo.UpdatePublication(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update publication: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *PublicationsHandler) DeletePublication(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid publication ID") + return + } + if err := h.repo.DeletePublication(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete publication: "+err.Error()) + return + } + httpx.NoContent(c) +} + +func (h *PublicationsHandler) GetHighlights(c *gin.Context) { + pubs, err := h.repo.GetHighlights(c.Request.Context()) + if err != nil { + httpx.InternalServerError(c, "Failed to retrieve highlight publications: "+err.Error()) + return + } + httpx.OK(c, pubs) } func (h *PublicationsHandler) GetPatents(c *gin.Context) { @@ -46,3 +117,58 @@ func (h *PublicationsHandler) GetPatents(c *gin.Context) { } httpx.OK(c, patents) } + +func (h *PublicationsHandler) CreatePatent(c *gin.Context) { + var req models.Patent + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" { + httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required") + return + } + id, err := h.repo.CreatePatent(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create patent: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *PublicationsHandler) UpdatePatent(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid patent ID") + return + } + var req models.Patent + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Title == "" || req.Inventors == "" || req.ApplicationNo == "" || req.ApplicationAt == "" || req.Country == "" || req.PublishedAt == "" { + httpx.BadRequest(c, "title, inventors, application_no, application_at, country, and published_at are required") + return + } + req.ID = id + if err := h.repo.UpdatePatent(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update patent: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *PublicationsHandler) DeletePatent(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid patent ID") + return + } + if err := h.repo.DeletePatent(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete patent: "+err.Error()) + return + } + httpx.NoContent(c) +} diff --git a/backend/internal/handlers/standardization.go b/backend/internal/handlers/standardization.go index 7ae949f..df34e92 100644 --- a/backend/internal/handlers/standardization.go +++ b/backend/internal/handlers/standardization.go @@ -1,11 +1,28 @@ package handlers import ( + "strconv" + "git.godopu.com/lab/landing_page/backend/internal/httpx" + "git.godopu.com/lab/landing_page/backend/internal/models" "git.godopu.com/lab/landing_page/backend/internal/repository" "github.com/gin-gonic/gin" ) +var validScopes = map[string]bool{ + "international": true, + "domestic": true, +} + +var validDocStatuses = map[string]bool{ + "IS": true, + "TS": true, + "TR": true, + "CDV": true, + "WDTR": true, + "InProgress": true, +} + type StandardizationHandler struct { repo *repository.StandardizationRepository } @@ -22,3 +39,135 @@ func (h *StandardizationHandler) GetStandardsBodies(c *gin.Context) { } httpx.OK(c, bodies) } + +func (h *StandardizationHandler) CreateStandardsBody(c *gin.Context) { + var req models.StandardsBody + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Org == "" || req.FullName == "" || req.Period == "" { + httpx.BadRequest(c, "org, full_name, and period are required") + return + } + if !validScopes[req.Scope] { + httpx.BadRequest(c, "scope must be 'international' or 'domestic'") + return + } + id, err := h.repo.CreateStandardsBody(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create standards body: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *StandardizationHandler) UpdateStandardsBody(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid standards body ID") + return + } + var req models.StandardsBody + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.Org == "" || req.FullName == "" || req.Period == "" { + httpx.BadRequest(c, "org, full_name, and period are required") + return + } + if !validScopes[req.Scope] { + httpx.BadRequest(c, "scope must be 'international' or 'domestic'") + return + } + req.ID = id + if err := h.repo.UpdateStandardsBody(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update standards body: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *StandardizationHandler) DeleteStandardsBody(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid standards body ID") + return + } + if err := h.repo.DeleteStandardsBody(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete standards body: "+err.Error()) + return + } + httpx.NoContent(c) +} + +func (h *StandardizationHandler) CreateStandardDocument(c *gin.Context) { + bodyID, err := strconv.Atoi(c.Param("bodyId")) + if err != nil || bodyID <= 0 { + httpx.BadRequest(c, "Invalid bodyId parameter") + return + } + var req models.StandardDocument + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" { + httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required") + return + } + if !validDocStatuses[req.Status] { + httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress") + return + } + req.BodyID = bodyID + id, err := h.repo.CreateStandardDocument(c.Request.Context(), req) + if err != nil { + httpx.InternalServerError(c, "Failed to create standard document: "+err.Error()) + return + } + req.ID = id + httpx.Created(c, req) +} + +func (h *StandardizationHandler) UpdateStandardDocument(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid document ID") + return + } + var req models.StandardDocument + if err := c.ShouldBindJSON(&req); err != nil { + httpx.BadRequest(c, "Invalid request body: "+err.Error()) + return + } + if req.WG == "" || req.ProjectName == "" || req.Title == "" || req.DocRef == "" { + httpx.BadRequest(c, "wg, project_name, title, and doc_ref are required") + return + } + if !validDocStatuses[req.Status] { + httpx.BadRequest(c, "status must be one of: IS, TS, TR, CDV, WDTR, InProgress") + return + } + req.ID = id + if err := h.repo.UpdateStandardDocument(c.Request.Context(), id, req); err != nil { + httpx.InternalServerError(c, "Failed to update standard document: "+err.Error()) + return + } + httpx.OK(c, req) +} + +func (h *StandardizationHandler) DeleteStandardDocument(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + httpx.BadRequest(c, "Invalid document ID") + return + } + if err := h.repo.DeleteStandardDocument(c.Request.Context(), id); err != nil { + httpx.InternalServerError(c, "Failed to delete standard document: "+err.Error()) + return + } + httpx.NoContent(c) +} diff --git a/backend/internal/httpx/auth.go b/backend/internal/httpx/auth.go new file mode 100644 index 0000000..19892e7 --- /dev/null +++ b/backend/internal/httpx/auth.go @@ -0,0 +1,34 @@ +package httpx + +import ( + "strings" + + "github.com/gin-gonic/gin" +) + +// RequireAdminToken returns a Gin middleware that validates Bearer against the configured admin token. +func RequireAdminToken(adminToken string) gin.HandlerFunc { + return func(c *gin.Context) { + if adminToken == "" { + Unauthorized(c, "Admin API is disabled: ADMIN_TOKEN is not configured") + c.Abort() + return + } + + auth := c.GetHeader("Authorization") + if auth == "" { + Unauthorized(c, "Missing Authorization header") + c.Abort() + return + } + + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != adminToken { + Unauthorized(c, "Invalid admin bearer token") + c.Abort() + return + } + + c.Next() + } +} diff --git a/backend/internal/httpx/response.go b/backend/internal/httpx/response.go index a2b8801..a656334 100644 --- a/backend/internal/httpx/response.go +++ b/backend/internal/httpx/response.go @@ -32,6 +32,20 @@ func Error(c *gin.Context, status int, msg string) { }) } +func Created(c *gin.Context, data any) { + c.JSON(http.StatusCreated, SuccessResponse{ + Data: data, + }) +} + +func NoContent(c *gin.Context) { + c.Status(http.StatusNoContent) +} + +func Unauthorized(c *gin.Context, msg string) { + Error(c, http.StatusUnauthorized, msg) +} + func BadRequest(c *gin.Context, msg string) { Error(c, http.StatusBadRequest, msg) } diff --git a/backend/internal/repository/home.go b/backend/internal/repository/home.go index fc94c96..c26dcab 100644 --- a/backend/internal/repository/home.go +++ b/backend/internal/repository/home.go @@ -55,6 +55,54 @@ func (r *HomeRepository) GetResearchProjects(ctx context.Context) ([]models.Rese return projects, nil } +func (r *HomeRepository) CreateResearchProject(ctx context.Context, p models.ResearchProject) (int, error) { + kwBytes, _ := json.Marshal(p.Keywords) + if p.Keywords == nil { + kwBytes = []byte("[]") + } + res, err := r.db.ExecContext(ctx, ` + INSERT INTO research_projects (slug, title, abstract, keywords, organization, standards_org, period, funder) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder) + if err != nil { + return 0, fmt.Errorf("insert research project: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *HomeRepository) UpdateResearchProject(ctx context.Context, id int, p models.ResearchProject) error { + kwBytes, _ := json.Marshal(p.Keywords) + if p.Keywords == nil { + kwBytes = []byte("[]") + } + res, err := r.db.ExecContext(ctx, ` + UPDATE research_projects + SET slug = ?, title = ?, abstract = ?, keywords = ?, organization = ?, standards_org = ?, period = ?, funder = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, p.Slug, p.Title, p.Abstract, string(kwBytes), p.Organization, p.StandardsOrg, p.Period, p.Funder, id) + if err != nil { + return fmt.Errorf("update research project: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *HomeRepository) DeleteResearchProject(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM research_projects WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete research project: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.ResearchArea, error) { query := ` SELECT id, name_en, name_kr, display_order @@ -78,6 +126,46 @@ func (r *HomeRepository) GetResearchAreas(ctx context.Context) ([]models.Researc return areas, rows.Err() } +func (r *HomeRepository) CreateResearchArea(ctx context.Context, a models.ResearchArea) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO research_areas (name_en, name_kr, display_order) + VALUES (?, ?, ?) + `, a.NameEn, a.NameKr, a.DisplayOrder) + if err != nil { + return 0, fmt.Errorf("insert research area: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *HomeRepository) UpdateResearchArea(ctx context.Context, id int, a models.ResearchArea) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE research_areas + SET name_en = ?, name_kr = ?, display_order = ? + WHERE id = ? + `, a.NameEn, a.NameKr, a.DisplayOrder, id) + if err != nil { + return fmt.Errorf("update research area: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *HomeRepository) DeleteResearchArea(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM research_areas WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete research area: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + func (r *HomeRepository) GetStatsSummary(ctx context.Context) (*models.StatsSummary, error) { var summary models.StatsSummary diff --git a/backend/internal/repository/lectures.go b/backend/internal/repository/lectures.go index 7691495..87fa97f 100644 --- a/backend/internal/repository/lectures.go +++ b/backend/internal/repository/lectures.go @@ -17,7 +17,6 @@ func NewLecturesRepository(db *sql.DB) *LecturesRepository { } func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]models.SemesterWithCourses, error) { - // Query semesters ordered by year DESC and Fall before Spring within same year semQuery := ` SELECT id, year, term FROM semesters @@ -30,7 +29,7 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod defer semRows.Close() var semesters []models.SemesterWithCourses - semMap := make(map[int]int) // semester_id -> index in slice + semMap := make(map[int]int) for semRows.Next() { var s models.SemesterWithCourses @@ -45,7 +44,6 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod return nil, err } - // Query courses ordered by display_order ASC, id ASC courseQuery := ` SELECT id, semester_id, code, name_en, name_kr, note, display_order FROM courses @@ -69,3 +67,83 @@ func (r *LecturesRepository) GetSemestersWithCourses(ctx context.Context) ([]mod return semesters, courseRows.Err() } + +func (r *LecturesRepository) CreateSemester(ctx context.Context, s models.Semester) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO semesters (year, term) + VALUES (?, ?) + `, s.Year, s.Term) + if err != nil { + return 0, fmt.Errorf("insert semester: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *LecturesRepository) UpdateSemester(ctx context.Context, id int, s models.Semester) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE semesters + SET year = ?, term = ? + WHERE id = ? + `, s.Year, s.Term, id) + if err != nil { + return fmt.Errorf("update semester: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *LecturesRepository) DeleteSemester(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM semesters WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete semester: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *LecturesRepository) CreateCourse(ctx context.Context, c models.Course) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO courses (semester_id, code, name_en, name_kr, note, display_order) + VALUES (?, ?, ?, ?, ?, ?) + `, c.SemesterID, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder) + if err != nil { + return 0, fmt.Errorf("insert course: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *LecturesRepository) UpdateCourse(ctx context.Context, id int, c models.Course) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE courses + SET code = ?, name_en = ?, name_kr = ?, note = ?, display_order = ? + WHERE id = ? + `, c.Code, c.NameEn, c.NameKr, c.Note, c.DisplayOrder, id) + if err != nil { + return fmt.Errorf("update course: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *LecturesRepository) DeleteCourse(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM courses WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete course: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} diff --git a/backend/internal/repository/members.go b/backend/internal/repository/members.go index 1b133b0..476c121 100644 --- a/backend/internal/repository/members.go +++ b/backend/internal/repository/members.go @@ -42,6 +42,46 @@ func (r *MembersRepository) GetMembers(ctx context.Context) ([]models.Member, er return members, rows.Err() } +func (r *MembersRepository) CreateMember(ctx context.Context, m models.Member) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder) + if err != nil { + return 0, fmt.Errorf("insert member: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *MembersRepository) UpdateMember(ctx context.Context, id int, m models.Member) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE members + SET name_ko = ?, name_en = ?, degree = ?, affiliation = ?, email = ?, is_advisor = ?, display_order = ? + WHERE id = ? + `, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder, id) + if err != nil { + return fmt.Errorf("update member: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *MembersRepository) DeleteMember(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM members WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete member: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, error) { query := ` SELECT id, name_ko, name_en, degree, graduated_at, major, current_position, created_at @@ -67,3 +107,43 @@ func (r *MembersRepository) GetAlumni(ctx context.Context) ([]models.Alumnus, er } return alumni, rows.Err() } + +func (r *MembersRepository) CreateAlumnus(ctx context.Context, a models.Alumnus) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position) + VALUES (?, ?, ?, ?, ?, ?) + `, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition) + if err != nil { + return 0, fmt.Errorf("insert alumnus: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *MembersRepository) UpdateAlumnus(ctx context.Context, id int, a models.Alumnus) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE alumni + SET name_ko = ?, name_en = ?, degree = ?, graduated_at = ?, major = ?, current_position = ? + WHERE id = ? + `, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition, id) + if err != nil { + return fmt.Errorf("update alumnus: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *MembersRepository) DeleteAlumnus(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM alumni WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete alumnus: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} diff --git a/backend/internal/repository/publications.go b/backend/internal/repository/publications.go index 1f3e074..2e12a7c 100644 --- a/backend/internal/repository/publications.go +++ b/backend/internal/repository/publications.go @@ -48,6 +48,46 @@ func (r *PublicationsRepository) GetPublications(ctx context.Context, category s return pubs, rows.Err() } +func (r *PublicationsRepository) CreatePublication(ctx context.Context, p models.Publication) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight) + if err != nil { + return 0, fmt.Errorf("insert publication: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *PublicationsRepository) UpdatePublication(ctx context.Context, id int, p models.Publication) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE publications + SET category = ?, title = ?, authors = ?, venue = ?, volume = ?, published_at = ?, kci = ?, doi = ?, is_highlight = ? + WHERE id = ? + `, p.Category, p.Title, p.Authors, p.Venue, p.Volume, p.PublishedAt, p.KCI, p.DOI, p.IsHighlight, id) + if err != nil { + return fmt.Errorf("update publication: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *PublicationsRepository) DeletePublication(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM publications WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete publication: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + func (r *PublicationsRepository) GetHighlights(ctx context.Context) ([]models.Publication, error) { query := ` SELECT id, category, title, authors, venue, volume, published_at, kci, doi, is_highlight, created_at @@ -100,3 +140,43 @@ func (r *PublicationsRepository) GetPatents(ctx context.Context) ([]models.Paten } return patents, rows.Err() } + +func (r *PublicationsRepository) CreatePatent(ctx context.Context, p models.Patent) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt) + if err != nil { + return 0, fmt.Errorf("insert patent: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *PublicationsRepository) UpdatePatent(ctx context.Context, id int, p models.Patent) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE patents + SET title = ?, inventors = ?, application_no = ?, application_at = ?, registration_no = ?, registration_at = ?, country = ?, published_at = ? + WHERE id = ? + `, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt, id) + if err != nil { + return fmt.Errorf("update patent: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *PublicationsRepository) DeletePatent(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM patents WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete patent: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} diff --git a/backend/internal/repository/standardization.go b/backend/internal/repository/standardization.go index e970ca1..a3829d8 100644 --- a/backend/internal/repository/standardization.go +++ b/backend/internal/repository/standardization.go @@ -17,7 +17,6 @@ func NewStandardizationRepository(db *sql.DB) *StandardizationRepository { } func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.Context) ([]models.StandardsBodyWithProjects, error) { - // Query bodies ordered by display_order bodyQuery := ` SELECT id, org, full_name, scope, period, role, display_order FROM standards_bodies @@ -30,7 +29,7 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C defer bodyRows.Close() var bodies []models.StandardsBodyWithProjects - bodyMap := make(map[int]int) // body_id -> index in bodies + bodyMap := make(map[int]int) for bodyRows.Next() { var b models.StandardsBodyWithProjects @@ -45,7 +44,6 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C return nil, err } - // Query documents ordered by body_id, and preserves curated insertion order with MIN(id) ASC docQuery := ` SELECT id, body_id, wg, project_name, title, doc_ref, status, published_at FROM standard_documents @@ -57,13 +55,12 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C } defer docRows.Close() - // Group documents into projects under each body preserving curated order type projectKey struct { bodyID int wg string projectName string } - projectMap := make(map[projectKey]int) // projectKey -> index in bodies[bIdx].Projects + projectMap := make(map[projectKey]int) for docRows.Next() { var d models.StandardDocument @@ -93,3 +90,83 @@ func (r *StandardizationRepository) GetStandardsBodiesWithProjects(ctx context.C return bodies, docRows.Err() } + +func (r *StandardizationRepository) CreateStandardsBody(ctx context.Context, b models.StandardsBody) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO standards_bodies (org, full_name, scope, period, role, display_order) + VALUES (?, ?, ?, ?, ?, ?) + `, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder) + if err != nil { + return 0, fmt.Errorf("insert standards body: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *StandardizationRepository) UpdateStandardsBody(ctx context.Context, id int, b models.StandardsBody) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE standards_bodies + SET org = ?, full_name = ?, scope = ?, period = ?, role = ?, display_order = ? + WHERE id = ? + `, b.Org, b.FullName, b.Scope, b.Period, b.Role, b.DisplayOrder, id) + if err != nil { + return fmt.Errorf("update standards body: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *StandardizationRepository) DeleteStandardsBody(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM standards_bodies WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete standards body: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *StandardizationRepository) CreateStandardDocument(ctx context.Context, d models.StandardDocument) (int, error) { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO standard_documents (body_id, wg, project_name, title, doc_ref, status, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, d.BodyID, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt) + if err != nil { + return 0, fmt.Errorf("insert standard document: %w", err) + } + id, err := res.LastInsertId() + return int(id), err +} + +func (r *StandardizationRepository) UpdateStandardDocument(ctx context.Context, id int, d models.StandardDocument) error { + res, err := r.db.ExecContext(ctx, ` + UPDATE standard_documents + SET wg = ?, project_name = ?, title = ?, doc_ref = ?, status = ?, published_at = ? + WHERE id = ? + `, d.WG, d.ProjectName, d.Title, d.DocRef, d.Status, d.PublishedAt, id) + if err != nil { + return fmt.Errorf("update standard document: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} + +func (r *StandardizationRepository) DeleteStandardDocument(ctx context.Context, id int) error { + res, err := r.db.ExecContext(ctx, `DELETE FROM standard_documents WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete standard document: %w", err) + } + rows, err := res.RowsAffected() + if err == nil && rows == 0 { + return sql.ErrNoRows + } + return err +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 584a31d..4d9e93e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" ) -func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine { +func SetupRouter(db *sql.DB, adminToken string, corsOrigins ...string) *gin.Engine { r := gin.Default() allowOrigin := "*" @@ -44,7 +44,7 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine { lecturesHandler := handlers.NewLecturesHandler(lecturesRepo) standardsHandler := handlers.NewStandardizationHandler(standardsRepo) - // API v1 Group + // API v1 Public Group (Read-only) v1 := r.Group("/api/v1") { v1.GET("/health", func(c *gin.Context) { @@ -72,5 +72,60 @@ func SetupRouter(db *sql.DB, corsOrigins ...string) *gin.Engine { v1.GET("/standards-bodies", standardsHandler.GetStandardsBodies) } + // API v1 Admin Group (Mutating operations requiring Bearer token) + admin := v1.Group("") + admin.Use(httpx.RequireAdminToken(adminToken)) + { + admin.GET("/admin/verify", func(c *gin.Context) { + httpx.OK(c, gin.H{"status": "authenticated"}) + }) + + // Research Projects + admin.POST("/research-projects", homeHandler.CreateResearchProject) + admin.PUT("/research-projects/:id", homeHandler.UpdateResearchProject) + admin.DELETE("/research-projects/:id", homeHandler.DeleteResearchProject) + + // Research Areas + admin.POST("/research-areas", homeHandler.CreateResearchArea) + admin.PUT("/research-areas/:id", homeHandler.UpdateResearchArea) + admin.DELETE("/research-areas/:id", homeHandler.DeleteResearchArea) + + // Members & Alumni + admin.POST("/members", membersHandler.CreateMember) + admin.PUT("/members/:id", membersHandler.UpdateMember) + admin.DELETE("/members/:id", membersHandler.DeleteMember) + + admin.POST("/alumni", membersHandler.CreateAlumnus) + admin.PUT("/alumni/:id", membersHandler.UpdateAlumnus) + admin.DELETE("/alumni/:id", membersHandler.DeleteAlumnus) + + // Publications & Patents + admin.POST("/publications", pubsHandler.CreatePublication) + admin.PUT("/publications/:id", pubsHandler.UpdatePublication) + admin.DELETE("/publications/:id", pubsHandler.DeletePublication) + + admin.POST("/patents", pubsHandler.CreatePatent) + admin.PUT("/patents/:id", pubsHandler.UpdatePatent) + admin.DELETE("/patents/:id", pubsHandler.DeletePatent) + + // Lectures (Semesters & Courses) + admin.POST("/semesters", lecturesHandler.CreateSemester) + admin.PUT("/semesters/:id", lecturesHandler.UpdateSemester) + admin.DELETE("/semesters/:id", lecturesHandler.DeleteSemester) + + admin.POST("/semesters/:semesterId/courses", lecturesHandler.CreateCourse) + admin.PUT("/courses/:id", lecturesHandler.UpdateCourse) + admin.DELETE("/courses/:id", lecturesHandler.DeleteCourse) + + // Standardization (Bodies & Documents) + admin.POST("/standards-bodies", standardsHandler.CreateStandardsBody) + admin.PUT("/standards-bodies/:id", standardsHandler.UpdateStandardsBody) + admin.DELETE("/standards-bodies/:id", standardsHandler.DeleteStandardsBody) + + admin.POST("/standards-bodies/:bodyId/documents", standardsHandler.CreateStandardDocument) + admin.PUT("/standard-documents/:id", standardsHandler.UpdateStandardDocument) + admin.DELETE("/standard-documents/:id", standardsHandler.DeleteStandardDocument) + } + return r } diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index 81566a5..630ac9c 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -1,6 +1,7 @@ package router_test import ( + "bytes" "context" "database/sql" "encoding/json" @@ -8,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "testing" "git.godopu.com/lab/landing_page/backend/internal/db" @@ -18,8 +20,9 @@ import ( _ "modernc.org/sqlite" ) +const testAdminToken = "test-secret-token" + func setupTestDB(t *testing.T) *sql.DB { - // In-memory or temporary file sqlite DB tmpDB := filepath.Join(t.TempDir(), "test.db") database, err := db.Connect(tmpDB) require.NoError(t, err) @@ -27,7 +30,6 @@ func setupTestDB(t *testing.T) *sql.DB { err = db.RunMigrations(database) require.NoError(t, err) - // Seed test data from ../../seed/data seedDir := filepath.Join("..", "..", "seed", "data") seedDatabase(t, database, seedDir) @@ -65,45 +67,75 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) { // 2. Areas aData, err := os.ReadFile(filepath.Join(seedDir, "research_areas.json")) require.NoError(t, err) - var areas []models.ResearchArea + var areas []struct { + En string `json:"en"` + Kr *string `json:"kr"` + } require.NoError(t, json.Unmarshal(aData, &areas)) - for _, a := range areas { - _, err := tx.Exec(`INSERT INTO research_areas (name_en, name_kr, display_order) VALUES (?, ?, ?)`, a.NameEn, a.NameKr, a.DisplayOrder) + for idx, a := range areas { + _, err := tx.Exec(` + INSERT INTO research_areas (name_en, name_kr, display_order) + VALUES (?, ?, ?) + `, a.En, a.Kr, idx+1) require.NoError(t, err) } // 3. Members mData, err := os.ReadFile(filepath.Join(seedDir, "members.json")) require.NoError(t, err) - var members []models.Member + var members []struct { + Ko string `json:"ko"` + En string `json:"en"` + Degree string `json:"degree"` + Affiliation *string `json:"affiliation"` + Email *string `json:"email"` + IsAdvisor bool `json:"is_advisor"` + } require.NoError(t, json.Unmarshal(mData, &members)) - for _, m := range members { + for idx, m := range members { _, err := tx.Exec(` INSERT INTO members (name_ko, name_en, degree, affiliation, email, is_advisor, display_order) VALUES (?, ?, ?, ?, ?, ?, ?) - `, m.NameKo, m.NameEn, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, m.DisplayOrder) + `, m.Ko, m.En, m.Degree, m.Affiliation, m.Email, m.IsAdvisor, idx+1) require.NoError(t, err) } // 4. Alumni alumniData, err := os.ReadFile(filepath.Join(seedDir, "alumni.json")) require.NoError(t, err) - var alumni []models.Alumnus + var alumni []struct { + Ko string `json:"ko"` + En string `json:"en"` + Degree string `json:"degree"` + GraduatedAt string `json:"graduatedAt"` + Major *string `json:"major"` + CurrentPosition *string `json:"currentPosition"` + } require.NoError(t, json.Unmarshal(alumniData, &alumni)) for _, a := range alumni { _, err := tx.Exec(` INSERT INTO alumni (name_ko, name_en, degree, graduated_at, major, current_position) VALUES (?, ?, ?, ?, ?, ?) - `, a.NameKo, a.NameEn, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition) + `, a.Ko, a.En, a.Degree, a.GraduatedAt, a.Major, a.CurrentPosition) require.NoError(t, err) } // 5. Publications pubData, err := os.ReadFile(filepath.Join(seedDir, "publications.json")) require.NoError(t, err) - var pubs []models.Publication - require.NoError(t, json.Unmarshal(pubData, &pubs)) - for _, p := range pubs { + var publications []struct { + Category string `json:"category"` + Title string `json:"title"` + Authors *string `json:"authors"` + Venue string `json:"venue"` + Volume *string `json:"volume"` + PublishedAt string `json:"publishedAt"` + KCI bool `json:"kci"` + DOI *string `json:"doi"` + IsHighlight bool `json:"is_highlight"` + } + require.NoError(t, json.Unmarshal(pubData, &publications)) + for _, p := range publications { _, err := tx.Exec(` INSERT INTO publications (category, title, authors, venue, volume, published_at, kci, doi, is_highlight) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -114,17 +146,26 @@ func seedDatabase(t *testing.T, database *sql.DB, seedDir string) { // 6. Patents patData, err := os.ReadFile(filepath.Join(seedDir, "patents.json")) require.NoError(t, err) - var patents []models.Patent + var patents []struct { + Title string `json:"title"` + Inventors string `json:"inventors"` + ApplicationNo string `json:"applicationNo"` + ApplicationAt string `json:"applicationAt"` + RegistrationNo *string `json:"registrationNo"` + RegistrationAt *string `json:"registrationAt"` + Country string `json:"country"` + PublishedAt string `json:"publishedAt"` + } require.NoError(t, json.Unmarshal(patData, &patents)) - for _, pat := range patents { + for _, p := range patents { _, err := tx.Exec(` INSERT INTO patents (title, inventors, application_no, application_at, registration_no, registration_at, country, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, pat.Title, pat.Inventors, pat.ApplicationNo, pat.ApplicationAt, pat.RegistrationNo, pat.RegistrationAt, pat.Country, pat.PublishedAt) + `, p.Title, p.Inventors, p.ApplicationNo, p.ApplicationAt, p.RegistrationNo, p.RegistrationAt, p.Country, p.PublishedAt) require.NoError(t, err) } - // 7. Semesters & Courses + // 7. Lectures lecData, err := os.ReadFile(filepath.Join(seedDir, "lectures.json")) require.NoError(t, err) var semesters []struct { @@ -200,247 +241,201 @@ type APIResponse[T any] struct { } `json:"error"` } -func executeRequest(r http.Handler, method, target string) *httptest.ResponseRecorder { - req := httptest.NewRequest(method, target, nil) +func doRequest(r http.Handler, method, target string, body any, token string) *httptest.ResponseRecorder { + var reqBody []byte + if body != nil { + reqBody, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, target, bytes.NewReader(reqBody)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } -func TestHealthEndpoint(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - w := executeRequest(r, "GET", "/api/v1/health") +func TestHealthCheck(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r := router.SetupRouter(db, testAdminToken) + w := doRequest(r, "GET", "/api/v1/health", nil, "") assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[map[string]any] - err := json.Unmarshal(w.Body.Bytes(), &resp) +} + +func TestAdminAuthMiddleware(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + + // A. Configured with explicit valid token + r := router.SetupRouter(db, testAdminToken) + + // 1. Unauthenticated request to /admin/verify should return 401 + wNoAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "") + assert.Equal(t, http.StatusUnauthorized, wNoAuth.Code) + + // 2. Guessable fallback token "admin123" must return 401 + wGuessAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "admin123") + assert.Equal(t, http.StatusUnauthorized, wGuessAuth.Code) + + // 3. Wrong token request to /admin/verify should return 401 + wWrongAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, "wrong-token") + assert.Equal(t, http.StatusUnauthorized, wWrongAuth.Code) + + // 4. Valid token request to /admin/verify should return 200 + wValidAuth := doRequest(r, "GET", "/api/v1/admin/verify", nil, testAdminToken) + assert.Equal(t, http.StatusOK, wValidAuth.Code) + + // 5. Public GET routes remain accessible without auth + wPublic := doRequest(r, "GET", "/api/v1/members", nil, "") + assert.Equal(t, http.StatusOK, wPublic.Code) + + // B. Unconfigured (empty string) admin token: must strictly reject ALL mutating/verify attempts + rEmptyToken := router.SetupRouter(db, "") + + // 6. Any token on verify should return 401 + wEmpty1 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "admin123") + assert.Equal(t, http.StatusUnauthorized, wEmpty1.Code) + + wEmpty2 := doRequest(rEmptyToken, "GET", "/api/v1/admin/verify", nil, "") + assert.Equal(t, http.StatusUnauthorized, wEmpty2.Code) + + // 7. Mutating POST on empty router should return 401 + newProj := models.ResearchProject{ + Slug: "test-unauth", + Title: "Unauthorized Title", + Abstract: "Unauthorized abstract.", + Keywords: []string{"unauth"}, + } + wMutate := doRequest(rEmptyToken, "POST", "/api/v1/research-projects", newProj, "admin123") + assert.Equal(t, http.StatusUnauthorized, wMutate.Code) + + // 8. Public GET routes still work on empty admin token router + wPublicEmpty := doRequest(rEmptyToken, "GET", "/api/v1/health", nil, "") + assert.Equal(t, http.StatusOK, wPublicEmpty.Code) +} + +func TestResearchProjectsCRUD(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r := router.SetupRouter(db, testAdminToken) + + // 1. Create Project + newProj := models.ResearchProject{ + Slug: "test-crud-proj", + Title: "Test Project Title", + Abstract: "Test project abstract for CRUD verification.", + Keywords: []string{"test", "crud"}, + } + wCreate := doRequest(r, "POST", "/api/v1/research-projects", newProj, testAdminToken) + assert.Equal(t, http.StatusCreated, wCreate.Code) + + var createResp APIResponse[models.ResearchProject] + require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) + createdID := createResp.Data.ID + assert.True(t, createdID > 0) + + // 2. Update Project + newProj.Title = "Updated Project Title" + wUpdate := doRequest(r, "PUT", "/api/v1/research-projects/"+strconvItoa(createdID), newProj, testAdminToken) + assert.Equal(t, http.StatusOK, wUpdate.Code) + + // 3. Delete Project + wDelete := doRequest(r, "DELETE", "/api/v1/research-projects/"+strconvItoa(createdID), nil, testAdminToken) + assert.Equal(t, http.StatusNoContent, wDelete.Code) +} + +func TestMembersAndAlumniCRUD(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r := router.SetupRouter(db, testAdminToken) + + // 1. Create Member with Invalid Degree -> 400 + badMember := models.Member{ + NameKo: "홍길동", + NameEn: "Gildong Hong", + Degree: "InvalidDegree", + } + wBad := doRequest(r, "POST", "/api/v1/members", badMember, testAdminToken) + assert.Equal(t, http.StatusBadRequest, wBad.Code) + + // 2. Create Valid Member -> 201 + validMember := models.Member{ + NameKo: "홍길동", + NameEn: "Gildong Hong", + Degree: "PhDStudent", + } + wCreate := doRequest(r, "POST", "/api/v1/members", validMember, testAdminToken) + assert.Equal(t, http.StatusCreated, wCreate.Code) + + var createResp APIResponse[models.Member] + require.NoError(t, json.Unmarshal(wCreate.Body.Bytes(), &createResp)) + mID := createResp.Data.ID + + // 3. Delete Member -> 204 + wDelete := doRequest(r, "DELETE", "/api/v1/members/"+strconvItoa(mID), nil, testAdminToken) + assert.Equal(t, http.StatusNoContent, wDelete.Code) + + // 4. Create Alumnus -> 201 + validAlumnus := models.Alumnus{ + NameKo: "김졸업", + NameEn: "Grad Kim", + Degree: "MS", + GraduatedAt: "2024-02", + } + wCreateAl := doRequest(r, "POST", "/api/v1/alumni", validAlumnus, testAdminToken) + assert.Equal(t, http.StatusCreated, wCreateAl.Code) + + var alResp APIResponse[models.Alumnus] + require.NoError(t, json.Unmarshal(wCreateAl.Body.Bytes(), &alResp)) + alID := alResp.Data.ID + + // 5. Delete Alumnus -> 204 + wDelAl := doRequest(r, "DELETE", "/api/v1/alumni/"+strconvItoa(alID), nil, testAdminToken) + assert.Equal(t, http.StatusNoContent, wDelAl.Code) +} + +func TestCascadeDeletes(t *testing.T) { + db := setupTestDB(t) + defer db.Close() + r := router.SetupRouter(db, testAdminToken) + + // 1. Create a semester + newSem := models.Semester{Year: 2030, Term: "Spring"} + wSem := doRequest(r, "POST", "/api/v1/semesters", newSem, testAdminToken) + assert.Equal(t, http.StatusCreated, wSem.Code) + var semResp APIResponse[models.Semester] + require.NoError(t, json.Unmarshal(wSem.Body.Bytes(), &semResp)) + semID := semResp.Data.ID + + // 2. Create courses under this semester + course1 := models.Course{Code: "TEST101", NameEn: "Intro to Testing"} + course2 := models.Course{Code: "TEST102", NameEn: "Advanced Testing"} + wCourse1 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course1, testAdminToken) + assert.Equal(t, http.StatusCreated, wCourse1.Code) + wCourse2 := doRequest(r, "POST", "/api/v1/semesters/"+strconvItoa(semID)+"/courses", course2, testAdminToken) + assert.Equal(t, http.StatusCreated, wCourse2.Code) + + // Verify courses exist in DB + var courseCount int + err := db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount) require.NoError(t, err) - assert.Equal(t, "ok", resp.Data["status"]) - assert.Equal(t, "anl-backend-api", resp.Data["service"]) + assert.Equal(t, 2, courseCount) + + // 3. Delete semester + wDelSem := doRequest(r, "DELETE", "/api/v1/semesters/"+strconvItoa(semID), nil, testAdminToken) + assert.Equal(t, http.StatusNoContent, wDelSem.Code) + + // 4. Verify cascade delete removed courses + err = db.QueryRow("SELECT COUNT(*) FROM courses WHERE semester_id = ?", semID).Scan(&courseCount) + require.NoError(t, err) + assert.Equal(t, 0, courseCount, "Courses should be cascaded when parent semester is deleted") } -func TestResearchProjects(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - w := executeRequest(r, "GET", "/api/v1/research-projects") - - assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[[]models.ResearchProject] - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Len(t, resp.Data, 3) - assert.Equal(t, "iot-standards", resp.Data[0].Slug) - assert.Equal(t, "AIoT & AI-RAN", resp.Data[0].Title) -} - -func TestResearchAreas(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - w := executeRequest(r, "GET", "/api/v1/research-areas") - - assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[[]models.ResearchArea] - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Len(t, resp.Data, 14) - assert.Equal(t, 1, resp.Data[0].DisplayOrder) - assert.Equal(t, "Quick UDP Internet Connection (QUIC)", resp.Data[0].NameEn) -} - -func TestStatsSummary(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - w := executeRequest(r, "GET", "/api/v1/stats/summary") - - assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[models.StatsSummary] - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Equal(t, 68, resp.Data.IntlPublications) - assert.Equal(t, 44, resp.Data.StandardizationDocs) - assert.Equal(t, 75, resp.Data.Patents) -} - -func TestMembersAndAlumni(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - - // Members - wMem := executeRequest(r, "GET", "/api/v1/members") - assert.Equal(t, http.StatusOK, wMem.Code) - var respMem APIResponse[[]models.Member] - require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &respMem)) - assert.Len(t, respMem.Data, 16) - assert.True(t, respMem.Data[0].IsAdvisor) - assert.Equal(t, "Seok-Joo Koh", respMem.Data[0].NameEn) - - // Alumni - wAlumni := executeRequest(r, "GET", "/api/v1/alumni") - assert.Equal(t, http.StatusOK, wAlumni.Code) - var respAlumni APIResponse[[]models.Alumnus] - require.NoError(t, json.Unmarshal(wAlumni.Body.Bytes(), &respAlumni)) - assert.Len(t, respAlumni.Data, 60) - assert.GreaterOrEqual(t, respAlumni.Data[0].GraduatedAt, respAlumni.Data[1].GraduatedAt) -} - -func TestPublicationsAndHighlights(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - - // Full Publications - wAll := executeRequest(r, "GET", "/api/v1/publications") - assert.Equal(t, http.StatusOK, wAll.Code) - var respAll APIResponse[[]models.Publication] - require.NoError(t, json.Unmarshal(wAll.Body.Bytes(), &respAll)) - assert.Len(t, respAll.Data, 148) - - // Category filter: intl - wIntl := executeRequest(r, "GET", "/api/v1/publications?category=intl-journal-conf") - assert.Equal(t, http.StatusOK, wIntl.Code) - var respIntl APIResponse[[]models.Publication] - require.NoError(t, json.Unmarshal(wIntl.Body.Bytes(), &respIntl)) - assert.Len(t, respIntl.Data, 68) - - // Category filter: domestic - wDom := executeRequest(r, "GET", "/api/v1/publications?category=domestic-journal-conf") - assert.Equal(t, http.StatusOK, wDom.Code) - var respDom APIResponse[[]models.Publication] - require.NoError(t, json.Unmarshal(wDom.Body.Bytes(), &respDom)) - assert.Len(t, respDom.Data, 80) - - // Invalid category - wInvalid := executeRequest(r, "GET", "/api/v1/publications?category=unknown") - assert.Equal(t, http.StatusBadRequest, wInvalid.Code) - - // Highlights (asserts exactly the 5 known representative titles) - wHL := executeRequest(r, "GET", "/api/v1/publications/highlights") - assert.Equal(t, http.StatusOK, wHL.Code) - var respHL APIResponse[[]models.Publication] - require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &respHL)) - assert.Len(t, respHL.Data, 5) - - expectedTitles := map[string]bool{ - "Globally Integrated Trust Authority (GITA) for Resource-Constrained Edge Devices in IoT and 6G": true, - "Application Level Trust Authority (APPLETA) for Resource-Constrained Edge Devices in IoT and 6G": true, - "mQUIC: Use of QUIC for Handover Support with Connection Migration in Wireless/Mobile Networks": true, - "Use of QUIC for CoAP Transport in IoT Networks": true, - "Use of QUIC for Mobile-Oriented Future Internet (Q-MOFI)": true, - } - for _, hl := range respHL.Data { - assert.True(t, hl.IsHighlight) - assert.True(t, expectedTitles[hl.Title], "Unexpected highlight title: %s", hl.Title) - } - - // Patents - wPat := executeRequest(r, "GET", "/api/v1/patents") - assert.Equal(t, http.StatusOK, wPat.Code) - var respPat APIResponse[[]models.Patent] - require.NoError(t, json.Unmarshal(wPat.Body.Bytes(), &respPat)) - assert.Len(t, respPat.Data, 75) -} - -func TestSemestersAndCourses(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - - w := executeRequest(r, "GET", "/api/v1/semesters") - assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[[]models.SemesterWithCourses] - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Len(t, resp.Data, 45) - - // Check semester ordering (Year DESC, Fall before Spring within same year) - first := resp.Data[0] - assert.Equal(t, 2026, first.Year) - assert.Equal(t, "Spring", first.Term) - assert.NotEmpty(t, first.Courses) - - // In 2025: Fall must come before Spring - var s2025Fall, s2025Spring int - for idx, s := range resp.Data { - if s.Year == 2025 && s.Term == "Fall" { - s2025Fall = idx - } - if s.Year == 2025 && s.Term == "Spring" { - s2025Spring = idx - } - } - assert.Less(t, s2025Fall, s2025Spring, "2025 Fall should precede 2025 Spring in chronological descending order") -} - -func TestStandardsBodiesAndHierarchy(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - - w := executeRequest(r, "GET", "/api/v1/standards-bodies") - assert.Equal(t, http.StatusOK, w.Code) - var resp APIResponse[[]models.StandardsBodyWithProjects] - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - assert.Len(t, resp.Data, 6) - - // First body: IEC TC100 - iec := resp.Data[0] - assert.Equal(t, "IEC TC100", iec.Org) - require.NotEmpty(t, iec.Projects) - - // Verify project ordering within IEC TC100 (deterministic order: WG12 before WG11(Convenor)) - assert.Equal(t, "WG12", iec.Projects[0].WG) - assert.Equal(t, "WG11(Convenor)", iec.Projects[1].WG) - assert.NotEmpty(t, iec.Projects[0].Documents) -} - -func TestJSONKeyCasingSnakeCase(t *testing.T) { - database := setupTestDB(t) - defer database.Close() - - r := router.SetupRouter(database) - - // Test Members endpoint raw JSON map - wMem := executeRequest(r, "GET", "/api/v1/members") - assert.Equal(t, http.StatusOK, wMem.Code) - var rawMem map[string]any - require.NoError(t, json.Unmarshal(wMem.Body.Bytes(), &rawMem)) - dataList, ok := rawMem["data"].([]any) - require.True(t, ok) - require.NotEmpty(t, dataList) - firstMember, ok := dataList[0].(map[string]any) - require.True(t, ok) - - // Verify snake_case keys exist and camelCase keys do not - assert.Contains(t, firstMember, "name_ko") - assert.Contains(t, firstMember, "name_en") - assert.Contains(t, firstMember, "is_advisor") - assert.Contains(t, firstMember, "display_order") - assert.NotContains(t, firstMember, "nameKo") - assert.NotContains(t, firstMember, "isAdvisor") - - // Test Highlights endpoint raw JSON map - wHL := executeRequest(r, "GET", "/api/v1/publications/highlights") - assert.Equal(t, http.StatusOK, wHL.Code) - var rawHL map[string]any - require.NoError(t, json.Unmarshal(wHL.Body.Bytes(), &rawHL)) - hlList, ok := rawHL["data"].([]any) - require.True(t, ok) - require.NotEmpty(t, hlList) - firstHL, ok := hlList[0].(map[string]any) - require.True(t, ok) - - assert.Contains(t, firstHL, "is_highlight") - assert.Contains(t, firstHL, "published_at") - assert.NotContains(t, firstHL, "isHighlight") - assert.NotContains(t, firstHL, "publishedAt") +func strconvItoa(i int) string { + return strconv.Itoa(i) } diff --git a/refer_landing_page/app/console/_components/AdminComponents.tsx b/refer_landing_page/app/console/_components/AdminComponents.tsx new file mode 100644 index 0000000..8dd46df --- /dev/null +++ b/refer_landing_page/app/console/_components/AdminComponents.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export function getClientToken(): string { + if (typeof window === "undefined") return ""; + const match = document.cookie.match(new RegExp("(^| )admin_token=([^;]+)")); + if (match) return decodeURIComponent(match[2]); + return localStorage.getItem("admin_token") || ""; +} + +export function setClientToken(token: string) { + if (typeof window === "undefined") return; + document.cookie = `admin_token=${encodeURIComponent(token)}; path=/; max-age=604800; SameSite=Lax`; + localStorage.setItem("admin_token", token); +} + +export function removeClientToken() { + if (typeof window === "undefined") return; + document.cookie = "admin_token=; path=/; max-age=0; SameSite=Lax"; + localStorage.removeItem("admin_token"); +} + +export function AdminHeader({ title, description, action }: { title: string; description?: string; action?: React.ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ ); +} + +export function AlertBanner({ type, message, onClose }: { type: "error" | "success" | "info"; message: string; onClose?: () => void }) { + const styles = { + error: "bg-red-50 dark:bg-red-950/40 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300", + success: "bg-emerald-50 dark:bg-emerald-950/40 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300", + info: "bg-blue-50 dark:bg-blue-950/40 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300", + }; + + return ( +
+
{message}
+ {onClose && ( + + )} +
+ ); +} + +export function Modal({ isOpen, title, onClose, children }: { isOpen: boolean; title: string; onClose: () => void; children: React.ReactNode }) { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} + +export function ConfirmDeleteModal({ + isOpen, + title = "Confirm Deletion", + description = "Are you sure you want to delete this item? This action cannot be undone.", + onClose, + onConfirm, + isSubmitting, +}: { + isOpen: boolean; + title?: string; + description?: string; + onClose: () => void; + onConfirm: () => void; + isSubmitting?: boolean; +}) { + if (!isOpen) return null; + + return ( + +
+

{description}

+
+ + +
+
+
+ ); +} diff --git a/refer_landing_page/app/console/layout.tsx b/refer_landing_page/app/console/layout.tsx new file mode 100644 index 0000000..2b9b2b2 --- /dev/null +++ b/refer_landing_page/app/console/layout.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { getClientToken, removeClientToken } from "./_components/AdminComponents"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + useEffect(() => { + const token = getClientToken(); + if (!token && pathname !== "/console/login") { + router.push("/console/login"); + } else { + setIsAuthenticated(true); + } + }, [pathname, router]); + + if (pathname === "/console/login") { + return
{children}
; + } + + if (isAuthenticated === null) { + return ( +
+
Checking authentication...
+
+ ); + } + + const navItems = [ + { href: "/console", label: "Dashboard Overview" }, + { href: "/console/research-projects", label: "Research Projects" }, + { href: "/console/research-areas", label: "Research Areas" }, + { href: "/console/members", label: "Members & Alumni" }, + { href: "/console/publications", label: "Publications & Patents" }, + { href: "/console/lectures", label: "Lectures & Courses" }, + { href: "/console/standardization", label: "Standardization" }, + ]; + + const handleLogout = () => { + removeClientToken(); + router.push("/console/login"); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main Content Area */} +
{children}
+
+ ); +} diff --git a/refer_landing_page/app/console/lectures/page.tsx b/refer_landing_page/app/console/lectures/page.tsx new file mode 100644 index 0000000..46bcdd1 --- /dev/null +++ b/refer_landing_page/app/console/lectures/page.tsx @@ -0,0 +1,473 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents"; +import { + adminGetSemesters, + adminCreateSemester, + adminUpdateSemester, + adminDeleteSemester, + adminCreateCourse, + adminUpdateCourse, + adminDeleteCourse, + AdminSemester, + AdminCourse, +} from "../../../lib/adminApi"; + +export default function AdminLecturesPage() { + const [semesters, setSemesters] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Semester Modal State + const [isSemModalOpen, setIsSemModalOpen] = useState(false); + const [editingSemester, setEditingSemester] = useState(null); + const [semForm, setSemForm] = useState({ + year: new Date().getFullYear(), + term: "Spring" as "Spring" | "Fall", + }); + + // Course Modal State + const [isCourseModalOpen, setIsCourseModalOpen] = useState(false); + const [targetSemesterId, setTargetSemesterId] = useState(null); + const [editingCourse, setEditingCourse] = useState(null); + const [courseForm, setCourseForm] = useState({ + code: "", + name_en: "", + name_kr: "", + note: "", + display_order: 0, + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingSemId, setDeletingSemId] = useState(null); + const [deletingCourseId, setDeletingCourseId] = useState(null); + + const loadSemesters = async () => { + try { + setIsLoading(true); + setError(null); + const data = await adminGetSemesters(); + setSemesters(data); + } catch (err: any) { + setError(err?.message || "Failed to load semesters."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadSemesters(); + }, []); + + // Semester Actions + const handleOpenSemCreate = () => { + setEditingSemester(null); + setSemForm({ + year: new Date().getFullYear(), + term: "Spring", + }); + setIsSemModalOpen(true); + }; + + const handleOpenSemEdit = (s: AdminSemester) => { + setEditingSemester(s); + setSemForm({ + year: s.year, + term: s.term, + }); + setIsSemModalOpen(true); + }; + + const handleSemSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) return; + + const payload = { + year: Number(semForm.year), + term: semForm.term, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingSemester) { + await adminUpdateSemester(token, editingSemester.id, payload); + setSuccessMsg(`Semester ${payload.year} ${payload.term} updated.`); + } else { + await adminCreateSemester(token, payload); + setSuccessMsg(`Semester ${payload.year} ${payload.term} created.`); + } + setIsSemModalOpen(false); + await loadSemesters(); + } catch (err: any) { + setError(err?.message || "Failed to save semester."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeleteSem = async () => { + if (!deletingSemId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeleteSemester(token, deletingSemId); + setSuccessMsg("Semester and all its courses deleted successfully (Cascade)."); + setDeletingSemId(null); + await loadSemesters(); + } catch (err: any) { + setError(err?.message || "Failed to delete semester."); + } finally { + setIsSubmitting(false); + } + }; + + // Course Actions + const handleOpenCourseCreate = (semesterId: number) => { + setTargetSemesterId(semesterId); + setEditingCourse(null); + const sem = semesters.find((s) => s.id === semesterId); + setCourseForm({ + code: "", + name_en: "", + name_kr: "", + note: "", + display_order: (sem?.courses?.length || 0) + 1, + }); + setIsCourseModalOpen(true); + }; + + const handleOpenCourseEdit = (c: AdminCourse) => { + setTargetSemesterId(c.semester_id); + setEditingCourse(c); + setCourseForm({ + code: c.code, + name_en: c.name_en, + name_kr: c.name_kr || "", + note: c.note || "", + display_order: c.display_order, + }); + setIsCourseModalOpen(true); + }; + + const handleCourseSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token || !targetSemesterId) return; + + const payload = { + code: courseForm.code.trim(), + name_en: courseForm.name_en.trim(), + name_kr: courseForm.name_kr.trim() || undefined, + note: courseForm.note.trim() || undefined, + display_order: Number(courseForm.display_order) || 0, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingCourse) { + await adminUpdateCourse(token, editingCourse.id, payload); + setSuccessMsg(`Course "${payload.name_en}" updated.`); + } else { + await adminCreateCourse(token, targetSemesterId, payload); + setSuccessMsg(`Course "${payload.name_en}" added.`); + } + setIsCourseModalOpen(false); + await loadSemesters(); + } catch (err: any) { + setError(err?.message || "Failed to save course."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeleteCourse = async () => { + if (!deletingCourseId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeleteCourse(token, deletingCourseId); + setSuccessMsg("Course deleted successfully."); + setDeletingCourseId(null); + await loadSemesters(); + } catch (err: any) { + setError(err?.message || "Failed to delete course."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + + Add New Semester + + } + /> + + {error && setError(null)} />} + {successMsg && setSuccessMsg(null)} />} + + {isLoading ? ( +
Loading semesters and courses...
+ ) : semesters.length === 0 ? ( +
+ No lecture semesters recorded. Click "+ Add New Semester" to get started. +
+ ) : ( +
+ {semesters.map((sem) => ( +
+ {/* Semester Header Bar */} +
+
+ + {sem.year} {sem.term} Semester + + + {sem.courses?.length || 0} Courses + +
+
+ + + +
+
+ + {/* Courses Table */} + + + + + + + + + + + + {!sem.courses || sem.courses.length === 0 ? ( + + + + ) : ( + sem.courses.map((c) => ( + + + + + + + + )) + )} + +
OrderCourse CodeCourse Name (En / Kr)NoteActions
+ No courses added for this semester. +
{c.display_order}{c.code} + {c.name_en} {c.name_kr && ({c.name_kr})} + {c.note || "—"} + + +
+
+ ))} +
+ )} + + {/* Semester Modal */} + setIsSemModalOpen(false)} + > +
+
+
+ + setSemForm({ ...semForm, year: Number(e.target.value) })} + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + +
+
+ +
+ + +
+
+
+ + {/* Course Modal */} + setIsCourseModalOpen(false)} + > +
+
+
+ + setCourseForm({ ...courseForm, code: e.target.value })} + placeholder="e.g. ITNT683" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setCourseForm({ ...courseForm, display_order: Number(e.target.value) })} + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+ + setCourseForm({ ...courseForm, name_en: e.target.value })} + placeholder="e.g. Digital Signal Processing" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + setCourseForm({ ...courseForm, name_kr: e.target.value })} + placeholder="e.g. 디지털신호처리" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + setCourseForm({ ...courseForm, note: e.target.value })} + placeholder="e.g. Graduate" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + +
+
+
+ + setDeletingSemId(null)} + onConfirm={handleConfirmDeleteSem} + isSubmitting={isSubmitting} + title="Delete Semester (Cascade Warning)" + description="Are you sure you want to delete this semester? All courses under this semester will also be permanently deleted (Database Cascade)." + /> + + setDeletingCourseId(null)} + onConfirm={handleConfirmDeleteCourse} + isSubmitting={isSubmitting} + title="Delete Course" + description="Are you sure you want to delete this course?" + /> +
+ ); +} diff --git a/refer_landing_page/app/console/login/page.tsx b/refer_landing_page/app/console/login/page.tsx new file mode 100644 index 0000000..f4cee52 --- /dev/null +++ b/refer_landing_page/app/console/login/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { setClientToken } from "../_components/AdminComponents"; +import { adminVerifyToken } from "../../../lib/adminApi"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [token, setToken] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!token.trim()) { + setError("Please enter the admin token."); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const isValid = await adminVerifyToken(token.trim()); + if (isValid) { + setClientToken(token.trim()); + router.push("/console"); + } else { + setError("Invalid admin bearer token. Please check your credentials."); + } + } catch (err: any) { + setError(err?.message || "Failed to verify token with backend API."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

ANL Admin Console

+

Enter your admin bearer token to manage laboratory datasets.

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setToken(e.target.value)} + placeholder="e.g. admin123" + className="w-full px-3 py-2 border border-line rounded-md bg-ivory text-ink focus:outline-none focus:ring-2 focus:ring-cobalt/50" + required + /> +
+ + +
+
+ ); +} diff --git a/refer_landing_page/app/console/members/page.tsx b/refer_landing_page/app/console/members/page.tsx new file mode 100644 index 0000000..ec5838e --- /dev/null +++ b/refer_landing_page/app/console/members/page.tsx @@ -0,0 +1,623 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents"; +import { + adminGetMembers, + adminCreateMember, + adminUpdateMember, + adminDeleteMember, + adminGetAlumni, + adminCreateAlumnus, + adminUpdateAlumnus, + adminDeleteAlumnus, + AdminMember, + AdminAlumnus, +} from "../../../lib/adminApi"; + +const MEMBER_DEGREES = [ + "Professor", + "PhD", + "PhDCandidate", + "PhDStudent", + "MSCandidate", + "MSStudent", +]; + +const ALUMNI_DEGREES = ["PhD", "MS"]; + +export default function AdminMembersPage() { + const [tab, setTab] = useState<"active" | "alumni">("active"); + const [members, setMembers] = useState([]); + const [alumni, setAlumni] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Active Member Modal State + const [isMemberModalOpen, setIsMemberModalOpen] = useState(false); + const [editingMember, setEditingMember] = useState(null); + const [memberForm, setMemberForm] = useState({ + name_ko: "", + name_en: "", + degree: "PhDStudent", + affiliation: "", + email: "", + is_advisor: false, + display_order: 0, + }); + + // Alumni Modal State + const [isAlumniModalOpen, setIsAlumniModalOpen] = useState(false); + const [editingAlumnus, setEditingAlumnus] = useState(null); + const [alumniForm, setAlumniForm] = useState({ + name_ko: "", + name_en: "", + degree: "MS", + graduated_at: "", + major: "", + current_position: "", + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingMemberId, setDeletingMemberId] = useState(null); + const [deletingAlumniId, setDeletingAlumniId] = useState(null); + + const loadData = async () => { + try { + setIsLoading(true); + setError(null); + const [mList, aList] = await Promise.all([adminGetMembers(), adminGetAlumni()]); + setMembers(mList); + setAlumni(aList); + } catch (err: any) { + setError(err?.message || "Failed to load members data."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + // Member Handlers + const handleOpenMemberCreate = () => { + setEditingMember(null); + setMemberForm({ + name_ko: "", + name_en: "", + degree: "PhDStudent", + affiliation: "", + email: "", + is_advisor: false, + display_order: members.length + 1, + }); + setIsMemberModalOpen(true); + }; + + const handleOpenMemberEdit = (m: AdminMember) => { + setEditingMember(m); + setMemberForm({ + name_ko: m.name_ko, + name_en: m.name_en, + degree: m.degree, + affiliation: m.affiliation || "", + email: m.email || "", + is_advisor: m.is_advisor, + display_order: m.display_order, + }); + setIsMemberModalOpen(true); + }; + + const handleMemberSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) { + setError("Unauthorized. Please log in."); + return; + } + + const payload = { + name_ko: memberForm.name_ko.trim(), + name_en: memberForm.name_en.trim(), + degree: memberForm.degree, + affiliation: memberForm.affiliation.trim() || undefined, + email: memberForm.email.trim() || undefined, + is_advisor: memberForm.is_advisor, + display_order: Number(memberForm.display_order) || 0, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingMember) { + await adminUpdateMember(token, editingMember.id, payload); + setSuccessMsg(`Member "${payload.name_ko}" updated successfully.`); + } else { + await adminCreateMember(token, payload); + setSuccessMsg(`Member "${payload.name_ko}" created successfully.`); + } + setIsMemberModalOpen(false); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to save member."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeleteMember = async () => { + if (!deletingMemberId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeleteMember(token, deletingMemberId); + setSuccessMsg("Member deleted successfully."); + setDeletingMemberId(null); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to delete member."); + } finally { + setIsSubmitting(false); + } + }; + + // Alumni Handlers + const handleOpenAlumniCreate = () => { + setEditingAlumnus(null); + setAlumniForm({ + name_ko: "", + name_en: "", + degree: "MS", + graduated_at: "", + major: "", + current_position: "", + }); + setIsAlumniModalOpen(true); + }; + + const handleOpenAlumniEdit = (a: AdminAlumnus) => { + setEditingAlumnus(a); + setAlumniForm({ + name_ko: a.name_ko, + name_en: a.name_en, + degree: a.degree, + graduated_at: a.graduated_at, + major: a.major || "", + current_position: a.current_position || "", + }); + setIsAlumniModalOpen(true); + }; + + const handleAlumniSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) return; + + const payload = { + name_ko: alumniForm.name_ko.trim(), + name_en: alumniForm.name_en.trim(), + degree: alumniForm.degree, + graduated_at: alumniForm.graduated_at.trim(), + major: alumniForm.major.trim() || undefined, + current_position: alumniForm.current_position.trim() || undefined, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingAlumnus) { + await adminUpdateAlumnus(token, editingAlumnus.id, payload); + setSuccessMsg(`Alumnus "${payload.name_ko}" updated successfully.`); + } else { + await adminCreateAlumnus(token, payload); + setSuccessMsg(`Alumnus "${payload.name_ko}" created successfully.`); + } + setIsAlumniModalOpen(false); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to save alumnus."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeleteAlumnus = async () => { + if (!deletingAlumniId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeleteAlumnus(token, deletingAlumniId); + setSuccessMsg("Alumnus deleted successfully."); + setDeletingAlumniId(null); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to delete alumnus."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + {tab === "active" ? "+ Add Active Member" : "+ Add Alumnus"} + + } + /> + + {/* Tabs */} +
+ + +
+ + {error && setError(null)} />} + {successMsg && setSuccessMsg(null)} />} + + {isLoading ? ( +
Loading records...
+ ) : tab === "active" ? ( + /* Active Members Table */ +
+ + + + + + + + + + + + {members.length === 0 ? ( + + + + ) : ( + members.map((m) => ( + + + + + + + + )) + )} + +
OrderName (Ko / En)DegreeAffiliation / EmailActions
+ No active members found. +
{m.display_order} +
+ {m.name_ko} ({m.name_en}) + {m.is_advisor && ( + + Advisor + + )} +
+
{m.degree} +
{m.affiliation || "—"}
+
{m.email || "—"}
+
+ + +
+
+ ) : ( + /* Alumni Table */ +
+ + + + + + + + + + + + {alumni.length === 0 ? ( + + + + ) : ( + alumni.map((a) => ( + + + + + + + + )) + )} + +
Graduation DateName (Ko / En)DegreeMajor / Current PositionActions
+ No alumni found. +
{a.graduated_at} + {a.name_ko} ({a.name_en}) + {a.degree} +
{a.current_position || "—"}
+
{a.major || "—"}
+
+ + +
+
+ )} + + {/* Member Modal */} + setIsMemberModalOpen(false)} + > +
+
+
+ + setMemberForm({ ...memberForm, name_ko: e.target.value })} + placeholder="e.g. 홍길동" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setMemberForm({ ...memberForm, name_en: e.target.value })} + placeholder="e.g. Gildong Hong" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + +
+
+ + setMemberForm({ ...memberForm, display_order: Number(e.target.value) })} + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + setMemberForm({ ...memberForm, affiliation: e.target.value })} + placeholder="e.g. School of Electronic Engineering" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setMemberForm({ ...memberForm, email: e.target.value })} + placeholder="e.g. user@knu.ac.kr" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+ setMemberForm({ ...memberForm, is_advisor: e.target.checked })} + className="rounded text-cobalt focus:ring-cobalt" + /> + +
+ +
+ + +
+
+
+ + {/* Alumni Modal */} + setIsAlumniModalOpen(false)} + > +
+
+
+ + setAlumniForm({ ...alumniForm, name_ko: e.target.value })} + placeholder="e.g. 홍길동" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setAlumniForm({ ...alumniForm, name_en: e.target.value })} + placeholder="e.g. Gildong Hong" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + setAlumniForm({ ...alumniForm, graduated_at: e.target.value })} + placeholder="e.g. 2024-02" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + +
+
+ +
+ + setAlumniForm({ ...alumniForm, current_position: e.target.value })} + placeholder="e.g. Samsung Electronics, Principal Engineer" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + +
+
+
+ + setDeletingMemberId(null)} + onConfirm={handleConfirmDeleteMember} + isSubmitting={isSubmitting} + title="Delete Member" + description="Are you sure you want to delete this member?" + /> + + setDeletingAlumniId(null)} + onConfirm={handleConfirmDeleteAlumnus} + isSubmitting={isSubmitting} + title="Delete Alumnus" + description="Are you sure you want to delete this alumnus?" + /> +
+ ); +} diff --git a/refer_landing_page/app/console/page.tsx b/refer_landing_page/app/console/page.tsx new file mode 100644 index 0000000..43a83de --- /dev/null +++ b/refer_landing_page/app/console/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminHeader, AlertBanner } from "./_components/AdminComponents"; +import { + adminGetStatsSummary, + adminGetResearchProjects, + adminGetResearchAreas, + adminGetMembers, + adminGetAlumni, + adminGetPublications, + adminGetPatents, + adminGetSemesters, + adminGetStandardsBodies, + AdminStatsSummary, +} from "../../lib/adminApi"; + +export default function AdminDashboardPage() { + const [stats, setStats] = useState(null); + const [counts, setCounts] = useState<{ + projects: number; + areas: number; + members: number; + alumni: number; + intlPubs: number; + domPubs: number; + patents: number; + semesters: number; + courses: number; + bodies: number; + docs: number; + }>({ + projects: 0, + areas: 0, + members: 0, + alumni: 0, + intlPubs: 0, + domPubs: 0, + patents: 0, + semesters: 0, + courses: 0, + bodies: 0, + docs: 0, + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadData() { + try { + setIsLoading(true); + const [ + statsData, + projects, + areas, + members, + alumni, + intlPubs, + domPubs, + patents, + semesters, + bodies, + ] = await Promise.all([ + adminGetStatsSummary().catch(() => null), + adminGetResearchProjects().catch(() => []), + adminGetResearchAreas().catch(() => []), + adminGetMembers().catch(() => []), + adminGetAlumni().catch(() => []), + adminGetPublications("intl-journal-conf").catch(() => []), + adminGetPublications("domestic-journal-conf").catch(() => []), + adminGetPatents().catch(() => []), + adminGetSemesters().catch(() => []), + adminGetStandardsBodies().catch(() => []), + ]); + + if (statsData) setStats(statsData); + + const totalCourses = semesters.reduce((acc, s) => acc + (s.courses?.length || 0), 0); + const totalDocs = bodies.reduce( + (acc, b) => acc + (b.projects?.reduce((pAcc, p) => pAcc + (p.documents?.length || 0), 0) || 0), + 0 + ); + + setCounts({ + projects: projects.length, + areas: areas.length, + members: members.length, + alumni: alumni.length, + intlPubs: intlPubs.length, + domPubs: domPubs.length, + patents: patents.length, + semesters: semesters.length, + courses: totalCourses, + bodies: bodies.length, + docs: totalDocs, + }); + } catch (err: any) { + setError(err?.message || "Failed to load dashboard data from backend API."); + } finally { + setIsLoading(false); + } + } + + loadData(); + }, []); + + const cards = [ + { + title: "Research Projects", + count: counts.projects, + sub: `${counts.areas} Research Areas`, + href: "/console/research-projects", + color: "border-cobalt/30 hover:border-cobalt", + }, + { + title: "Members & Alumni", + count: counts.members, + sub: `${counts.alumni} Alumni Records`, + href: "/console/members", + color: "border-emerald-500/30 hover:border-emerald-500", + }, + { + title: "Publications & Patents", + count: counts.intlPubs + counts.domPubs, + sub: `${counts.intlPubs} Intl / ${counts.domPubs} Dom / ${counts.patents} Patents`, + href: "/console/publications", + color: "border-blue-500/30 hover:border-blue-500", + }, + { + title: "Lectures & Courses", + count: counts.semesters, + sub: `${counts.courses} Total Courses across Semesters`, + href: "/console/lectures", + color: "border-amber-500/30 hover:border-amber-500", + }, + { + title: "Standardization", + count: counts.bodies, + sub: `${counts.docs} Standard Documents`, + href: "/console/standardization", + color: "border-purple-500/30 hover:border-purple-500", + }, + { + title: "Research Areas", + count: counts.areas, + sub: "Active Laboratory Focus Areas", + href: "/console/research-areas", + color: "border-indigo-500/30 hover:border-indigo-500", + }, + ]; + + return ( +
+ + + {error && setError(null)} />} + + {isLoading ? ( +
Loading dashboard statistics...
+ ) : ( +
+ {cards.map((card) => ( + +
+

{card.title}

+
{card.count}
+

{card.sub}

+
+
+ Manage Records + +
+ + ))} +
+ )} +
+ ); +} diff --git a/refer_landing_page/app/console/publications/page.tsx b/refer_landing_page/app/console/publications/page.tsx new file mode 100644 index 0000000..b19f69d --- /dev/null +++ b/refer_landing_page/app/console/publications/page.tsx @@ -0,0 +1,715 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents"; +import { + adminGetPublications, + adminCreatePublication, + adminUpdatePublication, + adminDeletePublication, + adminGetPatents, + adminCreatePatent, + adminUpdatePatent, + adminDeletePatent, + AdminPublication, + AdminPatent, +} from "../../../lib/adminApi"; + +export default function AdminPublicationsPage() { + const [tab, setTab] = useState<"publications" | "patents">("publications"); + const [publications, setPublications] = useState([]); + const [patents, setPatents] = useState([]); + const [filterCategory, setFilterCategory] = useState("all"); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Publication Modal State + const [isPubModalOpen, setIsPubModalOpen] = useState(false); + const [editingPub, setEditingPub] = useState(null); + const [pubForm, setPubForm] = useState({ + category: "intl-journal-conf" as "intl-journal-conf" | "domestic-journal-conf", + title: "", + authors: "", + venue: "", + volume: "", + published_at: "", + kci: false, + doi: "", + is_highlight: false, + }); + + // Patent Modal State + const [isPatentModalOpen, setIsPatentModalOpen] = useState(false); + const [editingPatent, setEditingPatent] = useState(null); + const [patentForm, setPatentForm] = useState({ + title: "", + inventors: "", + application_no: "", + application_at: "", + registration_no: "", + registration_at: "", + country: "Korea", + published_at: "", + }); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingPubId, setDeletingPubId] = useState(null); + const [deletingPatentId, setDeletingPatentId] = useState(null); + + const loadData = async () => { + try { + setIsLoading(true); + setError(null); + const [pubList, patList] = await Promise.all([adminGetPublications(), adminGetPatents()]); + setPublications(pubList); + setPatents(patList); + } catch (err: any) { + setError(err?.message || "Failed to load publications."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadData(); + }, []); + + // Pub Handlers + const handleOpenPubCreate = () => { + setEditingPub(null); + setPubForm({ + category: "intl-journal-conf", + title: "", + authors: "", + venue: "", + volume: "", + published_at: new Date().getFullYear().toString(), + kci: false, + doi: "", + is_highlight: false, + }); + setIsPubModalOpen(true); + }; + + const handleOpenPubEdit = (p: AdminPublication) => { + setEditingPub(p); + setPubForm({ + category: p.category, + title: p.title, + authors: p.authors || "", + venue: p.venue, + volume: p.volume || "", + published_at: p.published_at, + kci: p.kci, + doi: p.doi || "", + is_highlight: p.is_highlight, + }); + setIsPubModalOpen(true); + }; + + const handlePubSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) return; + + const payload = { + category: pubForm.category, + title: pubForm.title.trim(), + authors: pubForm.authors.trim() || undefined, + venue: pubForm.venue.trim(), + volume: pubForm.volume.trim() || undefined, + published_at: pubForm.published_at.trim(), + kci: pubForm.kci, + doi: pubForm.doi.trim() || undefined, + is_highlight: pubForm.is_highlight, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingPub) { + await adminUpdatePublication(token, editingPub.id, payload); + setSuccessMsg(`Publication "${payload.title}" updated.`); + } else { + await adminCreatePublication(token, payload); + setSuccessMsg(`Publication "${payload.title}" created.`); + } + setIsPubModalOpen(false); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to save publication."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeletePub = async () => { + if (!deletingPubId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeletePublication(token, deletingPubId); + setSuccessMsg("Publication deleted successfully."); + setDeletingPubId(null); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to delete publication."); + } finally { + setIsSubmitting(false); + } + }; + + // Patent Handlers + const handleOpenPatentCreate = () => { + setEditingPatent(null); + setPatentForm({ + title: "", + inventors: "", + application_no: "", + application_at: "", + registration_no: "", + registration_at: "", + country: "Korea", + published_at: new Date().getFullYear().toString(), + }); + setIsPatentModalOpen(true); + }; + + const handleOpenPatentEdit = (p: AdminPatent) => { + setEditingPatent(p); + setPatentForm({ + title: p.title, + inventors: p.inventors, + application_no: p.application_no, + application_at: p.application_at, + registration_no: p.registration_no || "", + registration_at: p.registration_at || "", + country: p.country, + published_at: p.published_at, + }); + setIsPatentModalOpen(true); + }; + + const handlePatentSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) return; + + const payload = { + title: patentForm.title.trim(), + inventors: patentForm.inventors.trim(), + application_no: patentForm.application_no.trim(), + application_at: patentForm.application_at.trim(), + registration_no: patentForm.registration_no.trim() || undefined, + registration_at: patentForm.registration_at.trim() || undefined, + country: patentForm.country.trim(), + published_at: patentForm.published_at.trim(), + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingPatent) { + await adminUpdatePatent(token, editingPatent.id, payload); + setSuccessMsg(`Patent "${payload.title}" updated.`); + } else { + await adminCreatePatent(token, payload); + setSuccessMsg(`Patent "${payload.title}" created.`); + } + setIsPatentModalOpen(false); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to save patent."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDeletePatent = async () => { + if (!deletingPatentId) return; + const token = getClientToken(); + if (!token) return; + + setIsSubmitting(true); + try { + await adminDeletePatent(token, deletingPatentId); + setSuccessMsg("Patent deleted successfully."); + setDeletingPatentId(null); + await loadData(); + } catch (err: any) { + setError(err?.message || "Failed to delete patent."); + } finally { + setIsSubmitting(false); + } + }; + + const filteredPubs = + filterCategory === "all" + ? publications + : publications.filter((p) => p.category === filterCategory); + + return ( +
+ + {tab === "publications" ? "+ Add Publication" : "+ Add Patent"} + + } + /> + + {/* Tabs */} +
+ + +
+ + {error && setError(null)} />} + {successMsg && setSuccessMsg(null)} />} + + {isLoading ? ( +
Loading records...
+ ) : tab === "publications" ? ( +
+ {/* Filter sub-bar */} +
+ Filter: + +
+ +
+ + + + + + + + + + + {filteredPubs.length === 0 ? ( + + + + ) : ( + filteredPubs.map((p) => ( + + + + + + + )) + )} + +
DateTitle & VenueCategoryActions
+ No publications found. +
{p.published_at} +
+ {p.title} + {p.is_highlight && ( + + Highlight + + )} + {p.kci && ( + + KCI + + )} +
+
+ {p.venue} {p.volume && `· ${p.volume}`} +
+ {p.authors &&
{p.authors}
} +
+ {p.category === "intl-journal-conf" ? "International" : "Domestic"} + + + +
+
+
+ ) : ( + /* Patents Table */ +
+ + + + + + + + + + + {patents.length === 0 ? ( + + + + ) : ( + patents.map((p) => ( + + + + + + + )) + )} + +
DateTitle & InventorsApp / Reg NumbersActions
+ No patents found. +
{p.published_at} +
{p.title}
+
{p.inventors}
+
+
App: {p.application_no} ({p.application_at})
+ {p.registration_no && ( +
+ Reg: {p.registration_no} ({p.registration_at || "—"}) +
+ )} +
+ + +
+
+ )} + + {/* Pub Modal */} + setIsPubModalOpen(false)} + > +
+
+
+ + +
+
+ + setPubForm({ ...pubForm, published_at: e.target.value })} + placeholder="e.g. 2023-11" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+ + setPubForm({ ...pubForm, title: e.target.value })} + placeholder="Full paper title" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + setPubForm({ ...pubForm, authors: e.target.value })} + placeholder="e.g. Gildong Hong, Prof. Advisor" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+
+ + setPubForm({ ...pubForm, venue: e.target.value })} + placeholder="e.g. IEEE Transactions on Communications" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setPubForm({ ...pubForm, volume: e.target.value })} + placeholder="e.g. Vol. 70, No. 3, pp. 120-135" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + setPubForm({ ...pubForm, doi: e.target.value })} + placeholder="e.g. 10.1109/TCOMM.2023.123456" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + +
+
+ +
+ + +
+
+
+ + {/* Patent Modal */} + setIsPatentModalOpen(false)} + > +
+
+ + setPatentForm({ ...patentForm, title: e.target.value })} + placeholder="e.g. Method and system for optical camera communications" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+
+ + setPatentForm({ ...patentForm, inventors: e.target.value })} + placeholder="e.g. Gildong Hong, Advisor" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setPatentForm({ ...patentForm, country: e.target.value })} + placeholder="e.g. Korea or USA" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + setPatentForm({ ...patentForm, application_no: e.target.value })} + placeholder="e.g. 10-2023-0123456" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setPatentForm({ ...patentForm, application_at: e.target.value })} + placeholder="e.g. 2023-05-12" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+
+ + setPatentForm({ ...patentForm, registration_no: e.target.value })} + placeholder="e.g. 10-2543210" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setPatentForm({ ...patentForm, registration_at: e.target.value })} + placeholder="e.g. 2024-01-10" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+ + setPatentForm({ ...patentForm, published_at: e.target.value })} + placeholder="e.g. 2023" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + +
+
+
+ + setDeletingPubId(null)} + onConfirm={handleConfirmDeletePub} + isSubmitting={isSubmitting} + title="Delete Publication" + description="Are you sure you want to delete this publication?" + /> + + setDeletingPatentId(null)} + onConfirm={handleConfirmDeletePatent} + isSubmitting={isSubmitting} + title="Delete Patent" + description="Are you sure you want to delete this patent record?" + /> +
+ ); +} diff --git a/refer_landing_page/app/console/research-areas/page.tsx b/refer_landing_page/app/console/research-areas/page.tsx new file mode 100644 index 0000000..f39e9bc --- /dev/null +++ b/refer_landing_page/app/console/research-areas/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents"; +import { + adminGetResearchAreas, + adminCreateResearchArea, + adminUpdateResearchArea, + adminDeleteResearchArea, + AdminResearchArea, +} from "../../../lib/adminApi"; + +export default function AdminResearchAreasPage() { + const [areas, setAreas] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingArea, setEditingArea] = useState(null); + const [formData, setFormData] = useState({ + name_en: "", + name_kr: "", + display_order: 0, + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const loadAreas = async () => { + try { + setIsLoading(true); + setError(null); + const data = await adminGetResearchAreas(); + setAreas(data); + } catch (err: any) { + setError(err?.message || "Failed to load research areas."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadAreas(); + }, []); + + const handleOpenCreate = () => { + setEditingArea(null); + setFormData({ + name_en: "", + name_kr: "", + display_order: areas.length + 1, + }); + setIsModalOpen(true); + }; + + const handleOpenEdit = (a: AdminResearchArea) => { + setEditingArea(a); + setFormData({ + name_en: a.name_en, + name_kr: a.name_kr || "", + display_order: a.display_order, + }); + setIsModalOpen(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) { + setError("Unauthorized. Please log in."); + return; + } + + const payload = { + name_en: formData.name_en.trim(), + name_kr: formData.name_kr.trim() || undefined, + display_order: Number(formData.display_order) || 0, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingArea) { + await adminUpdateResearchArea(token, editingArea.id, payload); + setSuccessMsg(`Research Area "${payload.name_en}" updated successfully.`); + } else { + await adminCreateResearchArea(token, payload); + setSuccessMsg(`Research Area "${payload.name_en}" created successfully.`); + } + setIsModalOpen(false); + await loadAreas(); + } catch (err: any) { + setError(err?.message || "Failed to save research area."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDelete = async () => { + if (!deletingId) return; + const token = getClientToken(); + if (!token) { + setError("Unauthorized. Please log in."); + return; + } + + setIsSubmitting(true); + try { + await adminDeleteResearchArea(token, deletingId); + setSuccessMsg("Research Area deleted successfully."); + setDeletingId(null); + await loadAreas(); + } catch (err: any) { + setError(err?.message || "Failed to delete research area."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + + Add New Area + + } + /> + + {error && setError(null)} />} + {successMsg && setSuccessMsg(null)} />} + + {isLoading ? ( +
Loading research areas...
+ ) : ( +
+ + + + + + + + + + + {areas.length === 0 ? ( + + + + ) : ( + areas.map((a) => ( + + + + + + + )) + )} + +
OrderEnglish NameKorean NameActions
+ No research areas found. +
{a.display_order}{a.name_en}{a.name_kr || "—"} + + +
+
+ )} + + {/* Modal */} + setIsModalOpen(false)} + > +
+
+ + setFormData({ ...formData, name_en: e.target.value })} + placeholder="e.g. Optical Wireless Communications" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + setFormData({ ...formData, name_kr: e.target.value })} + placeholder="e.g. 광무선 통신" + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + setFormData({ ...formData, display_order: Number(e.target.value) })} + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+ +
+ + +
+
+
+ + setDeletingId(null)} + onConfirm={handleConfirmDelete} + isSubmitting={isSubmitting} + title="Delete Research Area" + description="Are you sure you want to delete this research area keyword?" + /> +
+ ); +} diff --git a/refer_landing_page/app/console/research-projects/page.tsx b/refer_landing_page/app/console/research-projects/page.tsx new file mode 100644 index 0000000..b8c8599 --- /dev/null +++ b/refer_landing_page/app/console/research-projects/page.tsx @@ -0,0 +1,342 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { AdminHeader, AlertBanner, Modal, ConfirmDeleteModal, getClientToken } from "../_components/AdminComponents"; +import { + adminGetResearchProjects, + adminCreateResearchProject, + adminUpdateResearchProject, + adminDeleteResearchProject, + AdminResearchProject, +} from "../../../lib/adminApi"; + +export default function AdminResearchProjectsPage() { + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // Form State + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [formData, setFormData] = useState({ + slug: "", + title: "", + abstract: "", + keywords: "", + organization: "", + standards_org: "", + period: "", + funder: "", + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Delete State + const [deletingId, setDeletingId] = useState(null); + + const loadProjects = async () => { + try { + setIsLoading(true); + setError(null); + const data = await adminGetResearchProjects(); + setProjects(data); + } catch (err: any) { + setError(err?.message || "Failed to load research projects."); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadProjects(); + }, []); + + const handleOpenCreate = () => { + setEditingProject(null); + setFormData({ + slug: "", + title: "", + abstract: "", + keywords: "", + organization: "", + standards_org: "", + period: "", + funder: "", + }); + setIsModalOpen(true); + }; + + const handleOpenEdit = (p: AdminResearchProject) => { + setEditingProject(p); + setFormData({ + slug: p.slug, + title: p.title, + abstract: p.abstract, + keywords: (p.keywords || []).join(", "), + organization: p.organization || "", + standards_org: p.standards_org || "", + period: p.period || "", + funder: p.funder || "", + }); + setIsModalOpen(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const token = getClientToken(); + if (!token) { + setError("Unauthorized. Please log in."); + return; + } + + const kwArray = formData.keywords + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + + const payload = { + slug: formData.slug.trim(), + title: formData.title.trim(), + abstract: formData.abstract.trim(), + keywords: kwArray, + organization: formData.organization.trim() || undefined, + standards_org: formData.standards_org.trim() || undefined, + period: formData.period.trim() || undefined, + funder: formData.funder.trim() || undefined, + }; + + setIsSubmitting(true); + setError(null); + + try { + if (editingProject) { + await adminUpdateResearchProject(token, editingProject.id, payload); + setSuccessMsg(`Project "${payload.title}" updated successfully.`); + } else { + await adminCreateResearchProject(token, payload); + setSuccessMsg(`Project "${payload.title}" created successfully.`); + } + setIsModalOpen(false); + await loadProjects(); + } catch (err: any) { + setError(err?.message || "Failed to save project."); + } finally { + setIsSubmitting(false); + } + }; + + const handleConfirmDelete = async () => { + if (!deletingId) return; + const token = getClientToken(); + if (!token) { + setError("Unauthorized. Please log in."); + return; + } + + setIsSubmitting(true); + try { + await adminDeleteResearchProject(token, deletingId); + setSuccessMsg("Project deleted successfully."); + setDeletingId(null); + await loadProjects(); + } catch (err: any) { + setError(err?.message || "Failed to delete project."); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + + Add New Project + + } + /> + + {error && setError(null)} />} + {successMsg && setSuccessMsg(null)} />} + + {isLoading ? ( +
Loading projects...
+ ) : ( +
+ + + + + + + + + + + + {projects.length === 0 ? ( + + + + ) : ( + projects.map((p) => ( + + + + + + + + )) + )} + +
IDSlugTitleOrganization / PeriodActions
+ No research projects found. +
{p.id}{p.slug} +
{p.title}
+
{p.abstract}
+
+
{p.organization || p.standards_org || "—"}
+
{p.period || "—"}
+
+ + +
+
+ )} + + {/* Create / Edit Modal */} + setIsModalOpen(false)} + > +
+
+
+ + setFormData({ ...formData, slug: e.target.value })} + placeholder="e.g. ccis" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="Project title" + required + className="w-full px-3 py-2 text-sm border border-line rounded bg-ivory text-ink focus:outline-none focus:ring-1 focus:ring-cobalt" + /> +
+
+ +
+ +