diff --git a/management/src/api.ts b/management/src/api.ts index bbf98fb..723c7ab 100644 --- a/management/src/api.ts +++ b/management/src/api.ts @@ -86,6 +86,7 @@ export interface GitHubStatus { githubLogin?: string; connectedAt?: string; scope?: string; + canDeleteRepos?: boolean; } export const apiGitHub = { @@ -106,6 +107,37 @@ export const apiGitHub = { }, }; +export interface GitHubRepoItem { + key: string; + ownerLogin: string; + repoName: string; + repoHtmlUrl: string; + private?: boolean; + examRoomId?: number; + examRoomName?: string; + studentLabel?: string; + publishMode?: 'room' | 'student' | ''; + createdAt: string; + updatedAt?: string; + fromSimpleCare?: boolean; +} + +export const apiGitHubRepos = { + list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => { + const res = await staffFetch('/auth/github/repos'); + if (!res.ok) await parseError(res, 'Không tải danh sách repo'); + return res.json(); + }, + bulkDelete: async (repos: string[]) => { + const res = await staffFetch('/auth/github/repos/bulk-delete', { + method: 'POST', + body: JSON.stringify({ repos }), + }); + if (!res.ok) await parseError(res, 'Xóa repo thất bại'); + return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>; + }, +}; + export interface EmailDomainItem { id: number; domain: string; @@ -658,6 +690,8 @@ export interface ExamSubmission { createdAt: string; fullName: string; studentCode: string; + gitRepoUrl?: string; + gitPublishUrl?: string; } export const apiExam = { @@ -807,4 +841,16 @@ export const apiExam = { if (!res.ok) await parseError(res, 'Đẩy lên Git thất bại'); return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>; }, + publishSubmissionsGitStudents: async (id: number) => { + const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' }); + if (!res.ok) await parseError(res, 'Đẩy repo từng SV thất bại'); + return res.json() as Promise<{ + ok: boolean; + published: number; + openUrl?: string; + message: string; + warnings?: string[]; + studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[]; + }>; + }, }; diff --git a/management/src/components/AccountsTab.tsx b/management/src/components/AccountsTab.tsx index dde8fc0..3adbb34 100644 --- a/management/src/components/AccountsTab.tsx +++ b/management/src/components/AccountsTab.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useAuth } from '../auth/AuthContext'; import { apiAdmin, type EmailDomainItem } from '../api'; import { useGitHubConnection } from '../hooks/useGitHubConnection'; +import { GitHubReposPanel } from './GitHubReposPanel'; export function ChangePasswordPage({ forced }: { forced?: boolean }) { const { changePassword, logout } = useAuth(); @@ -177,6 +178,11 @@ export function MyAccountTab() { + {!gh.canDeleteRepos && ( + + )} ) : ( + {!gh.connected && ( @@ -858,7 +888,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { )} - ZIP gộp: mỗi sinh viên một folder — giải nén là chấm ngay. + Git gộp: 1 repo, folder từng SV · Git từng SV: mỗi người 1 repo. )} @@ -875,6 +905,7 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => { Sinh viên File + Git Thời gian Tải @@ -887,6 +918,11 @@ export const ExamRoomWorkspace: React.FC = ({ examId, onBack }) => {
{s.studentCode}
{s.fileName} + + {s.gitPublishUrl ? ( + Repo + ) : '—'} + {fmtTime(s.createdAt)} diff --git a/management/src/components/GitHubReposPanel.tsx b/management/src/components/GitHubReposPanel.tsx new file mode 100644 index 0000000..fcd570d --- /dev/null +++ b/management/src/components/GitHubReposPanel.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useState } from 'react'; +import { apiGitHubRepos, type GitHubRepoItem } from '../api'; + +export function GitHubReposPanel({ connected, canDeleteRepos = true }: { connected: boolean; canDeleteRepos?: boolean }) { + const [repos, setRepos] = useState([]); + const [githubLogin, setGithubLogin] = useState(''); + const [selected, setSelected] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [msg, setMsg] = useState(''); + const [err, setErr] = useState(''); + + const load = useCallback(async () => { + if (!connected) { + setRepos([]); + return; + } + setLoading(true); + setErr(''); + try { + const res = await apiGitHubRepos.list(); + setRepos(res.data || []); + setGithubLogin(res.githubLogin || ''); + setSelected(new Set()); + } catch (e: any) { + setErr(e?.message || 'Không tải repo'); + } finally { + setLoading(false); + } + }, [connected]); + + useEffect(() => { load().catch(console.error); }, [load]); + + const toggle = (key: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const toggleAll = () => { + if (selected.size === repos.length) setSelected(new Set()); + else setSelected(new Set(repos.map((r) => r.key))); + }; + + const deleteSelected = async () => { + if (selected.size === 0) return; + if (!canDeleteRepos) { + setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.'); + return; + } + if (!confirm(`Xóa ${selected.size} repo trên GitHub? Hành động không hoàn tác.`)) return; + setErr(''); setMsg(''); + setDeleting(true); + try { + const res = await apiGitHubRepos.bulkDelete([...selected]); + if (res.deleted > 0) { + setMsg(res.message || `Đã xóa ${res.deleted} repo`); + if (res.failures?.length) setErr(res.failures.join('; ')); + } else { + setMsg(''); + setErr(res.failures?.join('; ') || 'Không xóa được repo nào — thử ngắt kết nối rồi kết nối lại GitHub.'); + } + await load(); + } catch (e: any) { + setErr(e?.message || 'Xóa thất bại'); + } finally { + setDeleting(false); + } + }; + + const deleteOne = async (repo: GitHubRepoItem) => { + if (!canDeleteRepos) { + setErr('Chưa có quyền xóa repo — bấm Cấp quyền xóa repo ở trên.'); + return; + } + if (!confirm(`Xóa repo "${repo.repoName}" trên GitHub?`)) return; + setErr(''); setMsg(''); + setDeleting(true); + try { + const res = await apiGitHubRepos.bulkDelete([repo.key]); + if (res.deleted > 0) { + setMsg(res.message || 'Đã xóa repo'); + } else { + setMsg(''); + setErr(res.failures?.join('; ') || 'Không xóa được repo — thử ngắt kết nối rồi kết nối lại GitHub.'); + } + await load(); + } catch (e: any) { + setErr(e?.message || 'Xóa thất bại'); + } finally { + setDeleting(false); + } + }; + + if (!connected) return null; + + return ( +
+

+ Toàn bộ repo trên tài khoản GitHub{githubLogin ? ` @${githubLogin}` : ''} — có thể chọn và xóa nhiều repo cùng lúc. +

+
+ + {repos.length > 0 && ( + <> + + + + )} +
+ {err &&
{err}
} + {msg &&
{msg}
} + {repos.length === 0 && !loading ? ( +

Không có repo nào trên tài khoản GitHub (hoặc token thiếu quyền đọc repo).

+ ) : ( +
+ + + + + + + + + + + {repos.map((r) => ( + + + + + + + + + ))} + +
+ RepoLoạiPhòng / SVCập nhật +
+ toggle(r.key)} /> + + + {r.repoName} + + {r.private && private} + + {r.publishMode === 'student' ? ( + Từng SV + ) : r.publishMode === 'room' ? ( + Gộp phòng + ) : r.fromSimpleCare ? ( + Simple Care + ) : ( + Khác + )} + + {r.examRoomName &&
{r.examRoomName}
} + {r.studentLabel &&
{r.studentLabel}
} + {!r.examRoomName && !r.studentLabel && '—'} +
+ {r.updatedAt ? new Date(r.updatedAt).toLocaleString('vi-VN') : new Date(r.createdAt).toLocaleString('vi-VN')} + + +
+
+ )} +
+ ); +} diff --git a/management/src/hooks/useGitHubConnection.ts b/management/src/hooks/useGitHubConnection.ts index a9043d3..d7a5edc 100644 --- a/management/src/hooks/useGitHubConnection.ts +++ b/management/src/hooks/useGitHubConnection.ts @@ -4,6 +4,7 @@ import { apiGitHub } from '../api'; export function useGitHubConnection() { const [connected, setConnected] = useState(false); const [githubLogin, setGithubLogin] = useState(''); + const [canDeleteRepos, setCanDeleteRepos] = useState(false); const [connecting, setConnecting] = useState(false); const [error, setError] = useState(''); const [message, setMessage] = useState(''); @@ -13,10 +14,12 @@ export function useGitHubConnection() { const st = await apiGitHub.status(); setConnected(!!st.connected); setGithubLogin(st.githubLogin || ''); + setCanDeleteRepos(!!st.canDeleteRepos); } catch (e) { console.error(e); setConnected(false); setGithubLogin(''); + setCanDeleteRepos(false); } }, []); @@ -65,6 +68,7 @@ export function useGitHubConnection() { await apiGitHub.disconnect(); setConnected(false); setGithubLogin(''); + setCanDeleteRepos(false); setMessage('Đã ngắt kết nối GitHub'); } catch (e: any) { setError(e?.message || 'Ngắt kết nối thất bại'); @@ -74,6 +78,7 @@ export function useGitHubConnection() { return { connected, githubLogin, + canDeleteRepos, connecting, error, message, diff --git a/management/src/index.css b/management/src/index.css index 02f2b79..295c3a7 100644 --- a/management/src/index.css +++ b/management/src/index.css @@ -4312,6 +4312,34 @@ input:checked + .slider:before { color: var(--danger); } +.github-repos-panel { + margin-top: 0.75rem; +} + +.github-repos-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.github-repos-table-wrap { + max-height: 360px; + overflow: auto; +} + +.github-repo-link { + color: var(--accent); + font-weight: 500; + word-break: break-all; +} + +.section-subtitle { + font-size: 0.95rem; + font-weight: 600; + margin: 0; +} + .exam-git-branch-row { display: flex; flex-wrap: wrap; diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 9aae724..e2bc4fb 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -63,6 +63,7 @@ func AutoMigrate(db *gorm.DB) error { &models.ExamRoomStudent{}, &models.ExamSubmission{}, &models.StaffGitHubAuth{}, + &models.StaffGitHubRepo{}, ); err != nil { return err } diff --git a/server/internal/handlers/handlers_exam_submissions.go b/server/internal/handlers/handlers_exam_submissions.go index e9d79df..ff9255b 100644 --- a/server/internal/handlers/handlers_exam_submissions.go +++ b/server/internal/handlers/handlers_exam_submissions.go @@ -231,6 +231,10 @@ func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings [] } func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) { + return collectSubmissionZipFilesWithPrefix(rows, true) +} + +func collectSubmissionZipFilesWithPrefix(rows []submissionRow, useStudentFolder bool) (files []gitPathFile, warnings []string) { byPath := map[string]gitPathFile{} usedFolders := map[string]int{} for _, row := range rows { @@ -238,13 +242,17 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID)) continue } - folder := studentSubmissionFolder(row.StudentCode, row.FullName) - if n := usedFolders[folder]; n > 0 { - folder = fmt.Sprintf("%s_%d", folder, n+1) + prefix := "" + if useStudentFolder { + folder := studentSubmissionFolder(row.StudentCode, row.FullName) + if n := usedFolders[folder]; n > 0 { + folder = fmt.Sprintf("%s_%d", folder, n+1) + } + usedFolders[folder]++ + prefix = folder } - usedFolders[folder]++ - skipped, err := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error { + skipped, err := copySubmissionZipEntries(row.FilePath, prefix, func(destPath string, open func() (io.ReadCloser, error), size int64) error { if size > githubBlobMaxBytes { warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua") return nil @@ -262,11 +270,11 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni return nil }) if err != nil { - warnings = append(warnings, folder+": "+err.Error()) + warnings = append(warnings, prefix+": "+err.Error()) continue } for _, s := range skipped { - warnings = append(warnings, folder+"/"+s) + warnings = append(warnings, prefix+"/"+s) } } files = make([]gitPathFile, 0, len(byPath)) @@ -453,6 +461,8 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { room.GitPublishURL = viewURL _ = db.Save(&room).Error + roomIDCopy := roomID + recordStaffGitHubRepo(db, staffID, owner, repo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil) msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows)) if len(allWarn) > 0 { @@ -471,3 +481,112 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler { }) } } + +// POST /api/exam-rooms/:id/submissions/publish-git-students — mỗi sinh viên một repo GitHub riêng +func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + if staffID == 0 { + return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"}) + } + token, ghLogin, err := loadStaffGitHubToken(db, staffID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(401).JSON(fiber.Map{ + "error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.", + "needsGitHubOAuth": true, + }) + } + return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"}) + } + + roomID, err := parseUintParam(c, "id") + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid id"}) + } + var room models.ExamRoom + if err := db.First(&room, roomID).Error; err != nil { + return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"}) + } + + rows, err := loadExamSubmissionRows(db, roomID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + if len(rows) == 0 { + return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"}) + } + + branch := "main" + published := 0 + var allWarn []string + var repoURLs []string + type studentRepo struct { + StudentRkID int64 `json:"studentRkId"` + StudentCode string `json:"studentCode"` + FullName string `json:"fullName"` + URL string `json:"url"` + } + studentRepos := make([]studentRepo, 0, len(rows)) + + for _, row := range rows { + files, unpackWarn := collectSubmissionZipFilesWithPrefix([]submissionRow{row}, false) + allWarn = append(allWarn, unpackWarn...) + if len(files) == 0 { + allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName))) + continue + } + repoName, repoHTML, err := githubEnsureRepoForStudent(token, ghLogin, &room, &row) + if err != nil { + allWarn = append(allWarn, row.StudentCode+": "+err.Error()) + continue + } + label := studentSubmissionFolder(row.StudentCode, row.FullName) + commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name) + viewURL, pushWarn, err := githubPushFiles(token, ghLogin, repoName, branch, files, commitMsg) + allWarn = append(allWarn, pushWarn...) + if err != nil { + allWarn = append(allWarn, label+": "+err.Error()) + continue + } + _ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID). + Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error + subID := row.ID + roomIDCopy := roomID + recordStaffGitHubRepo(db, staffID, ghLogin, repoName, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID) + published++ + repoURLs = append(repoURLs, viewURL) + studentRepos = append(studentRepos, studentRepo{ + StudentRkID: row.StudentRkID, + StudentCode: row.StudentCode, + FullName: row.FullName, + URL: viewURL, + }) + } + + if published == 0 { + return c.Status(502).JSON(fiber.Map{ + "error": "Không đẩy được repo nào", + "warnings": allWarn, + }) + } + + msg := fmt.Sprintf("Đã tạo %d repo GitHub — mỗi sinh viên một repo.", published) + if len(allWarn) > 0 { + msg += fmt.Sprintf(" (%d cảnh báo)", len(allWarn)) + } + openURL := "" + if len(repoURLs) > 0 { + openURL = repoURLs[0] + } + + return c.JSON(fiber.Map{ + "ok": true, + "published": published, + "studentRepos": studentRepos, + "openUrl": openURL, + "warnings": allWarn, + "message": msg, + }) + } +} diff --git a/server/internal/handlers/handlers_github_oauth.go b/server/internal/handlers/handlers_github_oauth.go index 0624bc4..d2894cc 100644 --- a/server/internal/handlers/handlers_github_oauth.go +++ b/server/internal/handlers/handlers_github_oauth.go @@ -64,7 +64,7 @@ func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler { "https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s", url.QueryEscape(clientID), url.QueryEscape(redirectURI), - url.QueryEscape("repo"), + url.QueryEscape("repo delete_repo"), url.QueryEscape(state), ) return c.JSON(fiber.Map{"authorizeUrl": authorizeURL}) @@ -192,10 +192,11 @@ func GitHubStatusHandler(db *gorm.DB) fiber.Handler { return c.JSON(fiber.Map{"connected": false}) } return c.JSON(fiber.Map{ - "connected": true, - "githubLogin": row.GitHubLogin, - "connectedAt": row.ConnectedAt, - "scope": row.Scope, + "connected": true, + "githubLogin": row.GitHubLogin, + "connectedAt": row.ConnectedAt, + "scope": row.Scope, + "canDeleteRepos": githubScopeAllowsDelete(row.Scope), }) } } @@ -209,6 +210,21 @@ func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler { } } +func githubScopeAllowsDelete(scope string) bool { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return false + } + for _, part := range strings.FieldsFunc(scope, func(r rune) bool { + return r == ' ' || r == ',' + }) { + if part == "delete_repo" { + return true + } + } + return false +} + func githubGetUser(token string) (login string, err error) { req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) req.Header.Set("Authorization", "Bearer "+token) @@ -399,3 +415,61 @@ func resolvePublishRepo(token, ghLogin string, room *models.ExamRoom) (owner, re } return owner, repo, branch, htmlURL, nil } + +func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []string { + base := trimGitHubRepoName(strings.ToLower(studentSubmissionFolder(row.StudentCode, row.FullName))) + candidates := []string{ + base, + fmt.Sprintf("%s-exam-%d", base, room.ID), + fmt.Sprintf("%s-%d", base, row.StudentRkID), + fmt.Sprintf("exam-%d-%d", room.ID, row.StudentRkID), + } + seen := map[string]bool{} + out := make([]string, 0, len(candidates)) + for _, c := range candidates { + c = trimGitHubRepoName(c) + if seen[c] { + continue + } + seen[c] = true + out = append(out, c) + } + for i := 2; i <= 10; i++ { + c := trimGitHubRepoName(fmt.Sprintf("%s-%d", base, i)) + if !seen[c] { + seen[c] = true + out = append(out, c) + } + } + return out +} + +func githubEnsureRepoForStudent(token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) { + if u := strings.TrimSpace(row.GitRepoURL); u != "" { + o, r, parseErr := parseGitHubRepo(u) + if parseErr == nil && strings.EqualFold(o, owner) { + if url, ok := githubGetRepo(token, owner, r); ok { + return r, url, nil + } + } + } + for _, candidate := range studentGitRepoNameCandidates(*row, room) { + if url, ok := githubGetRepo(token, owner, candidate); ok { + if u := strings.TrimSpace(row.GitRepoURL); u != "" { + if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) { + return candidate, url, nil + } + } + continue + } + url, createErr := githubCreateRepo(token, owner, candidate, true) + if createErr == nil { + return candidate, url, nil + } + if isGitHubRepoNameTaken(createErr) { + continue + } + return "", "", createErr + } + return "", "", errors.New("không tạo được repo GitHub cho sinh viên") +} diff --git a/server/internal/handlers/handlers_github_repos.go b/server/internal/handlers/handlers_github_repos.go new file mode 100644 index 0000000..46c6735 --- /dev/null +++ b/server/internal/handlers/handlers_github_repos.go @@ -0,0 +1,322 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "server/internal/middleware" + "server/internal/models" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type githubRepoListItem struct { + Key string `json:"key"` + OwnerLogin string `json:"ownerLogin"` + RepoName string `json:"repoName"` + RepoHTMLURL string `json:"repoHtmlUrl"` + Private bool `json:"private"` + PublishMode string `json:"publishMode,omitempty"` + ExamRoomID *uint `json:"examRoomId,omitempty"` + ExamRoomName string `json:"examRoomName,omitempty"` + StudentLabel string `json:"studentLabel,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + FromSimpleCare bool `json:"fromSimpleCare"` +} + +func githubDeleteRepo(token, owner, repo string) error { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + req, err := http.NewRequest(http.MethodDelete, apiURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil + } + if resp.StatusCode >= 300 { + raw, _ := io.ReadAll(resp.Body) + msg := strings.TrimSpace(string(raw)) + var errBody struct { + Message string `json:"message"` + } + _ = json.Unmarshal(raw, &errBody) + if m := strings.TrimSpace(errBody.Message); m != "" { + msg = m + } + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("%s — token thiếu quyền delete_repo; ngắt kết nối rồi kết nối lại GitHub", msg) + } + return fmt.Errorf("%s", msg) + } + return nil +} + +func githubListUserRepos(token string) ([]struct { + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +}, error) { + var all []struct { + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + for page := 1; page <= 10; page++ { + apiURL := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&sort=updated&direction=desc", page) + req, err := http.NewRequest(http.MethodGet, apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + var batch []struct { + Name string `json:"name"` + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil { + resp.Body.Close() + return nil, err + } + resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode) + } + all = append(all, batch...) + if len(batch) < 100 { + break + } + } + return all, nil +} + +type repoMeta struct { + PublishMode string + ExamRoomID *uint + ExamRoomName string + StudentLabel string +} + +func repoMetaKey(owner, name string) string { + return strings.ToLower(owner) + "/" + strings.ToLower(name) +} + +func buildRepoMetaMap(db *gorm.DB, staffID uint) map[string]repoMeta { + out := map[string]repoMeta{} + var tracked []models.StaffGitHubRepo + _ = db.Where("staff_id = ?", staffID).Find(&tracked).Error + roomNames := map[uint]string{} + for _, t := range tracked { + key := repoMetaKey(t.OwnerLogin, t.RepoName) + meta := repoMeta{ + PublishMode: t.PublishMode, + ExamRoomID: t.ExamRoomID, + StudentLabel: t.StudentLabel, + } + if t.ExamRoomID != nil { + rid := *t.ExamRoomID + if name, ok := roomNames[rid]; ok { + meta.ExamRoomName = name + } else { + var room models.ExamRoom + if err := db.Select("name").First(&room, rid).Error; err == nil { + roomNames[rid] = room.Name + meta.ExamRoomName = room.Name + } + } + } + out[key] = meta + } + return out +} + +func recordStaffGitHubRepo(db *gorm.DB, staffID uint, owner, repoName, htmlURL, publishMode, studentLabel string, examRoomID, submissionID *uint) { + owner = strings.TrimSpace(owner) + repoName = strings.TrimSpace(repoName) + if staffID == 0 || owner == "" || repoName == "" || htmlURL == "" { + return + } + _ = db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, owner, repoName). + Delete(&models.StaffGitHubRepo{}).Error + row := models.StaffGitHubRepo{ + StaffID: staffID, + OwnerLogin: owner, + RepoName: repoName, + RepoHTMLURL: htmlURL, + ExamRoomID: examRoomID, + SubmissionID: submissionID, + StudentLabel: studentLabel, + PublishMode: publishMode, + } + _ = db.Create(&row).Error +} + +// GET /api/auth/github/repos — toàn bộ repo trên tài khoản GitHub đã kết nối +func GitHubListReposHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + token, ghLogin, err := loadStaffGitHubToken(db, staffID) + if err != nil { + return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"}) + } + ghRepos, err := githubListUserRepos(token) + if err != nil { + return c.Status(502).JSON(fiber.Map{"error": "Không tải repo từ GitHub: " + err.Error()}) + } + metaMap := buildRepoMetaMap(db, staffID) + out := make([]githubRepoListItem, 0, len(ghRepos)) + for _, r := range ghRepos { + parts := strings.SplitN(r.FullName, "/", 2) + owner := ghLogin + if len(parts) == 2 { + owner = parts[0] + } + key := owner + "/" + r.Name + item := githubRepoListItem{ + Key: key, + OwnerLogin: owner, + RepoName: r.Name, + RepoHTMLURL: r.HTMLURL, + Private: r.Private, + CreatedAt: r.CreatedAt.Format(time.RFC3339), + UpdatedAt: r.UpdatedAt.Format(time.RFC3339), + FromSimpleCare: strings.Contains(strings.ToLower(r.Description), "simple care"), + } + if meta, ok := metaMap[repoMetaKey(owner, r.Name)]; ok { + item.PublishMode = meta.PublishMode + item.ExamRoomID = meta.ExamRoomID + item.ExamRoomName = meta.ExamRoomName + item.StudentLabel = meta.StudentLabel + item.FromSimpleCare = true + } + out = append(out, item) + } + return c.JSON(fiber.Map{"data": out, "total": len(out), "githubLogin": ghLogin}) + } +} + +// POST /api/auth/github/repos/bulk-delete — repos: ["owner/name", ...] +func GitHubBulkDeleteReposHandler(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + staffID := middleware.StaffIDFromCtx(c) + token, _, err := loadStaffGitHubToken(db, staffID) + if err != nil { + return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"}) + } + var authRow models.StaffGitHubAuth + _ = db.First(&authRow, "staff_id = ?", staffID).Error + if !githubScopeAllowsDelete(authRow.Scope) { + return c.Status(403).JSON(fiber.Map{ + "error": "Token GitHub chưa có quyền xóa repo (delete_repo). Ngắt kết nối rồi bấm Kết nối lại GitHub.", + }) + } + var req struct { + Repos []string `json:"repos"` + IDs []uint `json:"ids"` // legacy + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid body"}) + } + targets := make([]struct{ owner, name string }, 0) + for _, raw := range req.Repos { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + parts := strings.SplitN(raw, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + continue + } + targets = append(targets, struct{ owner, name string }{owner: parts[0], name: parts[1]}) + } + if len(targets) == 0 && len(req.IDs) > 0 { + var rows []models.StaffGitHubRepo + _ = db.Where("staff_id = ? AND id IN ?", staffID, req.IDs).Find(&rows).Error + for _, row := range rows { + targets = append(targets, struct{ owner, name string }{owner: row.OwnerLogin, name: row.RepoName}) + } + } + if len(targets) == 0 { + return c.Status(400).JSON(fiber.Map{"error": "Chọn ít nhất một repo"}) + } + deleted := 0 + var failures []string + for _, t := range targets { + if err := githubDeleteRepo(token, t.owner, t.name); err != nil { + failures = append(failures, t.name+": "+err.Error()) + continue + } + var row models.StaffGitHubRepo + if err := db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, t.owner, t.name).First(&row).Error; err == nil { + _ = db.Delete(&row).Error + clearLinkedGitURLs(db, row) + } else { + clearLinkedGitURLsByName(db, t.owner, t.name) + } + deleted++ + } + return c.JSON(fiber.Map{ + "ok": deleted > 0, + "deleted": deleted, + "failures": failures, + "message": fmt.Sprintf("Đã xóa %d repo trên GitHub", deleted), + }) + } +} + +func clearLinkedGitURLs(db *gorm.DB, row models.StaffGitHubRepo) { + url := strings.TrimSpace(row.RepoHTMLURL) + if url == "" { + return + } + if row.ExamRoomID != nil && row.PublishMode == models.GitPublishModeRoom { + _ = db.Model(&models.ExamRoom{}).Where("id = ? AND git_repo_url = ?", *row.ExamRoomID, url). + Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error + } + if row.SubmissionID != nil { + _ = db.Model(&models.ExamSubmission{}).Where("id = ? AND git_repo_url = ?", *row.SubmissionID, url). + Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error + } +} + +func clearLinkedGitURLsByName(db *gorm.DB, owner, repoName string) { + prefix := fmt.Sprintf("https://github.com/%s/%s", owner, repoName) + _ = db.Model(&models.ExamRoom{}).Where("git_repo_url LIKE ?", prefix+"%"). + Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error + _ = db.Model(&models.ExamSubmission{}).Where("git_repo_url LIKE ?", prefix+"%"). + Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error +} diff --git a/server/internal/models/exam.go b/server/internal/models/exam.go index 92f682e..d0a3205 100644 --- a/server/internal/models/exam.go +++ b/server/internal/models/exam.go @@ -66,8 +66,10 @@ type ExamSubmission struct { CreatedAt time.Time `json:"createdAt"` ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"` StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"` - FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"` - FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"` + FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"` + FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"` + GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl,omitempty"` + GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"` } func (ExamSubmission) TableName() string { return "exam_submissions" } diff --git a/server/internal/models/staff_github_repo.go b/server/internal/models/staff_github_repo.go new file mode 100644 index 0000000..3307b95 --- /dev/null +++ b/server/internal/models/staff_github_repo.go @@ -0,0 +1,26 @@ +package models + +import "time" + +const ( + GitPublishModeRoom = "room" + GitPublishModeStudent = "student" +) + +// StaffGitHubRepo — repo GitHub do giáo viên tạo qua Simple Care +type StaffGitHubRepo struct { + ID uint `gorm:"primaryKey" json:"id"` + StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"` + OwnerLogin string `gorm:"column:owner_login;size:128;not null" json:"ownerLogin"` + RepoName string `gorm:"column:repo_name;size:128;not null" json:"repoName"` + RepoHTMLURL string `gorm:"column:repo_html_url;size:512;not null" json:"repoHtmlUrl"` + ExamRoomID *uint `gorm:"column:exam_room_id;index" json:"examRoomId,omitempty"` + ExamRoomName string `gorm:"-" json:"examRoomName,omitempty"` + SubmissionID *uint `gorm:"column:submission_id;index" json:"submissionId,omitempty"` + StudentLabel string `gorm:"column:student_label;size:255" json:"studentLabel,omitempty"` + PublishMode string `gorm:"column:publish_mode;size:16;not null;default:room" json:"publishMode"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (StaffGitHubRepo) TableName() string { return "staff_github_repos" } diff --git a/server/main.go b/server/main.go index 597bb2f..46fcf66 100644 --- a/server/main.go +++ b/server/main.go @@ -111,6 +111,8 @@ func main() { staff.Post("/auth/change-password", handlers.ChangePasswordHandler(gormDB)) staff.Get("/auth/github/authorize", handlers.GitHubAuthorizeURLHandler(gormDB)) staff.Get("/auth/github/status", handlers.GitHubStatusHandler(gormDB)) + staff.Get("/auth/github/repos", handlers.GitHubListReposHandler(gormDB)) + staff.Post("/auth/github/repos/bulk-delete", handlers.GitHubBulkDeleteReposHandler(gormDB)) staff.Delete("/auth/github", handlers.GitHubDisconnectHandler(gormDB)) staff.Get("/stats", handlers.GetStatsHandler(gormDB)) @@ -185,6 +187,7 @@ func main() { staff.Get("/exam-rooms/:id/submissions", handlers.ListExamSubmissionsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/download-all", handlers.DownloadAllExamSubmissionsHandler(gormDB)) staff.Post("/exam-rooms/:id/submissions/publish-git", handlers.PublishExamSubmissionsGitHandler(gormDB)) + staff.Post("/exam-rooms/:id/submissions/publish-git-students", handlers.PublishExamSubmissionsGitPerStudentHandler(gormDB)) staff.Patch("/exam-rooms/:id/git-settings", handlers.UpdateExamRoomGitSettingsHandler(gormDB)) staff.Get("/exam-rooms/:id/submissions/:subId/download", handlers.DownloadExamSubmissionHandler(gormDB))