= ({ 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).
+ ) : (
+
+ )}
+
+ );
+}
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))
|