feat: add document services, basic CRUD for entities - #1
Conversation
Review Summary by QodoPort backend to Go with complete document CRUD, AI processing, and role-based access control
WalkthroughsDescription• **Complete port of backend from Node.js to Go** with full CRUD operations for documents and metadata entities • **Document service layer** with 1600+ lines implementing document lifecycle (creation, upload, deletion), metadata management (subjects, types, languages, terms, sources), and reporting system • **HTTP handlers** for document operations (list, upload, details, edit, delete, report, dashboard) and admin management with role-based access control • **PostgreSQL repositories** for all domain entities including documents, chunks, chapters, files, upload jobs, users, and metadata with filtering, pagination, and sorting • **AI-powered document processing pipeline** with Gemini API integration for embeddings (3072 dimensions) and chapter segmentation with batch processing and retry logic • **Background worker** for 7-step document upload workflow including file parsing, chunking, embedding, and segmentation • **S3 file storage** implementation using AWS SDK v2 for cloud document persistence • **Authentication middleware** enhanced with user status validation and role-based authorization • **Separate Hono authentication service** on port 5000 with Better Auth configuration and CORS support • **Database schema** with pgvector extension, comprehensive indexes, and Better Auth integration tables • **API documentation** with Swagger/OpenAPI specification covering 30+ endpoints and complete Vietnamese API reference guide • **Bug fix** in PPTX parser refactored to use XML token streaming for robust text extraction • **Configuration updates** for embedding dimensions, auth service URLs, and environment variable support Diagramflowchart LR
Client["Client<br/>Frontend"]
AuthSvc["Hono Auth Service<br/>Port 5000"]
GoBackend["Go Backend<br/>Port 8080"]
DB["PostgreSQL<br/>pgvector"]
S3["AWS S3<br/>Storage"]
Gemini["Google Gemini<br/>API"]
Client -->|Login/Register| AuthSvc
Client -->|API Requests| GoBackend
GoBackend -->|Auth Check| AuthSvc
GoBackend -->|CRUD Operations| DB
GoBackend -->|File Upload/Download| S3
GoBackend -->|Embeddings & Segmentation| Gemini
DB -->|Vector Search| GoBackend
File Changes1. backend/go/docs/docs.go
|
Code Review by Qodo
1. Hardcoded DATABASE_URL and JWT_SECRET
|
|
Warning Review limit reached
More reviews will be available in 3 minutes and 36 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds a dedicated Hono/Better Auth service, restructures the Go backend with domains, repositories, router, Swagger, S3, worker, and segmentation, updates docs and frontend auth wiring, introduces migrations/config, and removes legacy code. Provides a Windows script to start both backends concurrently. ChangesAuth service + Go RAG backend restructure
Estimated code review effort🎯 5 (Critical) | ⏱️ ~150 minutes Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
| func Load() *Config { | ||
| return &Config{ | ||
| DATABASE_URL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/postgres"), | ||
| JWT_SECRET: getEnv("JWT_SECRET", "your-secret-key-min-32-characters-long"), | ||
| JWT_EXPIRY: getEnv("JWT_EXPIRY", "24h"), | ||
| GEMINI_API_KEY: getEnv("GEMINI_API_KEY", ""), | ||
| UPLOAD_DIR: getEnv("UPLOAD_DIR", "./uploads"), | ||
| MAX_FILE_SIZE: parseInt(getEnv("MAX_FILE_SIZE", "52428800")), | ||
| AWS_ACCESS_KEY_ID: getEnv("AWS_ACCESS_KEY_ID", ""), | ||
| AWS_SECRET_ACCESS_KEY: getEnv("AWS_SECRET_ACCESS_KEY", ""), | ||
| AWS_REGION: getEnv("AWS_REGION", "us-east-1"), | ||
| AWS_S3_BUCKET: getEnv("AWS_S3_BUCKET", ""), |
There was a problem hiding this comment.
1. Hardcoded database_url and jwt_secret 📘 Rule violation ⛨ Security
config.Load() provides hardcoded default values for DATABASE_URL (with credentials) and JWT_SECRET, allowing insecure fallback configuration and potential secret leakage. Production settings should require explicit environment configuration instead of embedded defaults.
Agent Prompt
## Issue description
`backend/go/pkg/config/env.go` hardcodes default values for runtime configuration (notably `DATABASE_URL` and `JWT_SECRET`). This violates the requirement to use environment variables for configuration and increases risk of accidentally running with insecure defaults.
## Issue Context
`DATABASE_URL` currently defaults to `postgres://postgres:********@localhost:5432/postgres` and `JWT_SECRET` defaults to `your-secret-key-min-32-characters-long`.
## Fix Focus Areas
- backend/go/pkg/config/env.go[37-48]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| "swd392-chatbot-rag/internal/domain/academicterm" | ||
| "swd392-chatbot-rag/internal/domain/auditlog" | ||
| "swd392-chatbot-rag/internal/domain/chapter" | ||
| "swd392-chatbot-rag/internal/domain/chunk" | ||
| "swd392-chatbot-rag/internal/domain/document" | ||
| "swd392-chatbot-rag/internal/domain/documentfile" | ||
| "swd392-chatbot-rag/internal/domain/documentreport" | ||
| "swd392-chatbot-rag/internal/domain/documentsource" | ||
| "swd392-chatbot-rag/internal/domain/documenttype" | ||
| "swd392-chatbot-rag/internal/domain/language" | ||
| "swd392-chatbot-rag/internal/domain/subject" | ||
| "swd392-chatbot-rag/internal/domain/uploadjob" | ||
| "swd392-chatbot-rag/internal/domain/user" | ||
| "swd392-chatbot-rag/internal/infrastructure/filestorage" | ||
|
|
||
| "github.com/google/uuid" | ||
| ) |
There was a problem hiding this comment.
2. documentservice depends on infrastructure 📘 Rule violation ⚙ Maintainability
DocumentService (application layer) directly imports internal/infrastructure/filestorage, violating Clean Architecture dependency direction. This couples use cases/business logic to infrastructure details and makes testing/replacement harder.
Agent Prompt
## Issue description
The application-layer `DocumentService` imports an infrastructure package (`internal/infrastructure/filestorage`), which breaks Clean Architecture dependency rules (inner layers should not depend on outer layers).
## Issue Context
`DocumentService` is in `internal/application` but directly imports `internal/infrastructure/filestorage`.
## Fix Focus Areas
- backend/go/internal/application/document_service.go[15-31]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| package application | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/md5" | ||
| crand "crypto/rand" | ||
| "encoding/hex" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "math" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "swd392-chatbot-rag/internal/domain/academicterm" | ||
| "swd392-chatbot-rag/internal/domain/auditlog" | ||
| "swd392-chatbot-rag/internal/domain/chapter" | ||
| "swd392-chatbot-rag/internal/domain/chunk" | ||
| "swd392-chatbot-rag/internal/domain/document" | ||
| "swd392-chatbot-rag/internal/domain/documentfile" | ||
| "swd392-chatbot-rag/internal/domain/documentreport" | ||
| "swd392-chatbot-rag/internal/domain/documentsource" | ||
| "swd392-chatbot-rag/internal/domain/documenttype" | ||
| "swd392-chatbot-rag/internal/domain/language" | ||
| "swd392-chatbot-rag/internal/domain/subject" | ||
| "swd392-chatbot-rag/internal/domain/uploadjob" | ||
| "swd392-chatbot-rag/internal/domain/user" | ||
| "swd392-chatbot-rag/internal/infrastructure/filestorage" | ||
|
|
||
| "github.com/google/uuid" | ||
| ) | ||
|
|
||
| var ( | ||
| AllowedExtensions = []string{".pdf", ".doc", ".docx", ".ppt", ".pptx"} | ||
| AllowedMimeTypes = map[string]bool{ | ||
| "application/pdf": true, | ||
| "application/msword": true, | ||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, | ||
| "application/vnd.ms-powerpoint": true, | ||
| "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, | ||
| } | ||
| ) | ||
|
|
||
| // DTO Definitions | ||
|
|
||
| type DocumentCreateInput struct { | ||
| Title string `json:"title"` | ||
| Description *string `json:"description"` | ||
| SubjectID *uuid.UUID `json:"subject_id"` | ||
| DocumentTypeID *uuid.UUID `json:"document_type_id"` | ||
| AcademicTermID *uuid.UUID `json:"academic_term_id"` | ||
| LanguageID *uuid.UUID `json:"language_id"` | ||
| Visibility *string `json:"visibility"` | ||
| DocumentSourceID *uuid.UUID `json:"document_source_id"` | ||
| OwnerUserID uuid.UUID `json:"owner_user_id"` | ||
| } | ||
|
|
||
| type DocumentEditInput struct { | ||
| Title string `json:"title"` | ||
| Description *string `json:"description"` | ||
| SubjectID *uuid.UUID `json:"subject_id"` | ||
| DocumentTypeID *uuid.UUID `json:"document_type_id"` | ||
| AcademicTermID *uuid.UUID `json:"academic_term_id"` | ||
| LanguageID *uuid.UUID `json:"language_id"` | ||
| Visibility string `json:"visibility"` | ||
| DocumentSourceID *uuid.UUID `json:"document_source_id"` | ||
| } | ||
|
|
||
| type DocumentCreateResultDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Slug string `json:"slug"` | ||
| } | ||
|
|
||
| type DocumentFileDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| DocumentID uuid.UUID `json:"document_id"` | ||
| OriginalFilename string `json:"original_filename"` | ||
| StoragePath string `json:"storage_path"` | ||
| S3Key *string `json:"s3_key"` | ||
| FileUrl *string `json:"file_url"` | ||
| MimeType *string `json:"mime_type"` | ||
| FileSizeBytes int64 `json:"file_size_bytes"` | ||
| PageCount *int `json:"page_count"` | ||
| ExtractionStatus string `json:"extraction_status"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentChapterDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| DocumentID uuid.UUID `json:"document_id"` | ||
| ParentChapterID *uuid.UUID `json:"parent_chapter_id,omitempty"` | ||
| Title string `json:"title"` | ||
| Summary *string `json:"summary,omitempty"` | ||
| ChapterOrder int `json:"chapter_order"` | ||
| StartPage *int `json:"start_page,omitempty"` | ||
| EndPage *int `json:"end_page,omitempty"` | ||
| StartChunkIndex *int `json:"start_chunk_index,omitempty"` | ||
| EndChunkIndex *int `json:"end_chunk_index,omitempty"` | ||
| IsAiGenerated bool `json:"is_ai_generated"` | ||
| ConfidenceScore *float64 `json:"confidence_score,omitempty"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentChunkDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| DocumentID uuid.UUID `json:"document_id"` | ||
| ChapterID *uuid.UUID `json:"chapter_id,omitempty"` | ||
| ChunkOrder int `json:"chunk_order"` | ||
| PageNumber *int `json:"page_number,omitempty"` | ||
| Content string `json:"content"` | ||
| ContentTokens *int `json:"content_tokens,omitempty"` | ||
| Metadata string `json:"metadata"` | ||
| ChunkHash string `json:"chunk_hash"` | ||
| HasEmbedding bool `json:"has_embedding"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentDetailsDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| OwnerUserID uuid.UUID `json:"owner_user_id"` | ||
| Title string `json:"title"` | ||
| SubjectID *uuid.UUID `json:"subject_id"` | ||
| SubjectName *string `json:"subject_name,omitempty"` | ||
| SubjectCode *string `json:"subject_code,omitempty"` | ||
| DocumentTypeID *uuid.UUID `json:"document_type_id"` | ||
| DocumentTypeName *string `json:"document_type_name,omitempty"` | ||
| AcademicTermName *string `json:"academic_term_name,omitempty"` | ||
| AcademicTermID *uuid.UUID `json:"academic_term_id"` | ||
| DocumentSourceID *uuid.UUID `json:"document_source_id"` | ||
| DocumentSourceName *string `json:"document_source_name,omitempty"` | ||
| Visibility string `json:"visibility"` | ||
| LanguageID *uuid.UUID `json:"language_id"` | ||
| LanguageCode *string `json:"language_code,omitempty"` | ||
| LanguageName *string `json:"language_name,omitempty"` | ||
| Description *string `json:"description"` | ||
| Status string `json:"status"` | ||
| TotalChunks int `json:"total_chunks"` | ||
| TotalChapters int `json:"total_chapters"` | ||
| ViewCount int `json:"view_count"` | ||
| DownloadCount int `json:"download_count"` | ||
| ApprovedAt *time.Time `json:"approved_at,omitempty"` | ||
| FileCount int `json:"file_count"` | ||
| Files []DocumentFileDto `json:"files"` | ||
| Chapters []DocumentChapterDto `json:"chapters"` | ||
| Chunks []DocumentChunkDto `json:"chunks"` | ||
| } | ||
|
|
||
| type UploadJobSummaryDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| DocumentID *uuid.UUID `json:"document_id,omitempty"` | ||
| FileName string `json:"file_name"` | ||
| FileSizeBytes int64 `json:"file_size_bytes"` | ||
| Status string `json:"status"` | ||
| ProgressPercent int `json:"progress_percent"` | ||
| Message *string `json:"message,omitempty"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| } | ||
|
|
||
| type DocumentListItemDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Slug string `json:"slug"` | ||
| Title string `json:"title"` | ||
| SubjectID *uuid.UUID `json:"subject_id"` | ||
| SubjectName *string `json:"subject_name,omitempty"` | ||
| SubjectCode *string `json:"subject_code,omitempty"` | ||
| DocumentTypeID *uuid.UUID `json:"document_type_id"` | ||
| DocumentTypeName *string `json:"document_type_name,omitempty"` | ||
| AcademicTermName *string `json:"academic_term_name,omitempty"` | ||
| Status string `json:"status"` | ||
| Visibility string `json:"visibility"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| FileCount int `json:"file_count"` | ||
| ChunkCount int `json:"chunk_count"` | ||
| PreviewText string `json:"preview_text"` | ||
| OwnerEmail *string `json:"owner_email,omitempty"` | ||
| ViewCount int `json:"view_count"` | ||
| } | ||
|
|
||
| type MyDocumentsDto struct { | ||
| Documents []DocumentListItemDto `json:"documents"` | ||
| TotalDocuments int `json:"total_documents"` | ||
| PendingDocuments int `json:"pending_documents"` | ||
| ApprovedDocuments int `json:"approved_documents"` | ||
| RejectedDocuments int `json:"rejected_documents"` | ||
| TotalFiles int `json:"total_files"` | ||
| TotalChunks int `json:"total_chunks"` | ||
| Page int `json:"page"` | ||
| PageSize int `json:"page_size"` | ||
| TotalPages int `json:"total_pages"` | ||
| ActiveUploadJobs []UploadJobSummaryDto `json:"active_upload_jobs"` | ||
| } | ||
|
|
||
| type DashboardRecentDocumentDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Slug string `json:"slug"` | ||
| Title string `json:"title"` | ||
| Subject *string `json:"subject,omitempty"` | ||
| Status string `json:"status"` | ||
| UpdatedAt time.Time `json:"updated_at"` | ||
| FileCount int `json:"file_count"` | ||
| ChunkCount int `json:"chunk_count"` | ||
| } | ||
|
|
||
| type DashboardSummaryDto struct { | ||
| TotalDocuments int `json:"total_documents"` | ||
| TotalChunks int `json:"total_chunks"` | ||
| TotalFiles int `json:"total_files"` | ||
| ApprovedDocuments int `json:"approved_documents"` | ||
| PendingDocuments int `json:"pending_documents"` | ||
| RejectedDocuments int `json:"rejected_documents"` | ||
| RecentDocuments []DashboardRecentDocumentDto `json:"recent_documents"` | ||
| ActiveUploadJobs []UploadJobSummaryDto `json:"active_upload_jobs"` | ||
| CompletedUploadMessage *string `json:"completed_upload_message,omitempty"` | ||
| } | ||
|
|
||
| type SubjectDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Code string `json:"code"` | ||
| Name string `json:"name"` | ||
| AcademicTermID *uuid.UUID `json:"academic_term_id,omitempty"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentTypeDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Name string `json:"name"` | ||
| Description *string `json:"description,omitempty"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type LanguageDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Code string `json:"code"` | ||
| Name string `json:"name"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentSourceDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Name string `json:"name"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type AcademicTermDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Name string `json:"name"` | ||
| Order int `json:"order"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| } | ||
|
|
||
| type DocumentReportDto struct { | ||
| ID uuid.UUID `json:"id"` | ||
| DocumentID uuid.UUID `json:"document_id"` | ||
| ReporterUserID uuid.UUID `json:"reporter_user_id"` | ||
| Reason string `json:"reason"` | ||
| Status string `json:"status"` | ||
| CreatedAt time.Time `json:"created_at"` | ||
| DocumentTitle *string `json:"document_title,omitempty"` | ||
| DocumentSlug *string `json:"document_slug,omitempty"` | ||
| ReporterEmail *string `json:"reporter_email,omitempty"` | ||
| } | ||
|
|
||
| type DeleteDocumentViewData struct { | ||
| ID uuid.UUID `json:"id"` | ||
| Title string `json:"title"` | ||
| FileCount int `json:"file_count"` | ||
| ChunkCount int `json:"chunk_count"` | ||
| } | ||
|
|
||
| // Service Implementation | ||
|
|
||
| type DocumentService struct { | ||
| docRepo document.DocumentRepository | ||
| fileRepo documentfile.DocumentFileRepository | ||
| chunkRepo chunk.ChunkRepository | ||
| chapterRepo chapter.ChapterRepository | ||
| subjectRepo subject.SubjectRepository | ||
| termRepo academicterm.AcademicTermRepository | ||
| typeRepo documenttype.DocumentTypeRepository | ||
| langRepo language.LanguageRepository | ||
| sourceRepo documentsource.DocumentSourceRepository | ||
| reportRepo documentreport.DocumentReportRepository | ||
| jobRepo uploadjob.UploadJobRepository | ||
| userRepo user.UserRepository | ||
| auditRepo auditlog.AuditLogRepository | ||
| s3Storage *filestorage.S3FileStorage | ||
| } | ||
|
|
||
| func NewDocumentService( | ||
| docRepo document.DocumentRepository, | ||
| fileRepo documentfile.DocumentFileRepository, | ||
| chunkRepo chunk.ChunkRepository, | ||
| chapterRepo chapter.ChapterRepository, | ||
| subjectRepo subject.SubjectRepository, | ||
| termRepo academicterm.AcademicTermRepository, | ||
| typeRepo documenttype.DocumentTypeRepository, | ||
| langRepo language.LanguageRepository, | ||
| sourceRepo documentsource.DocumentSourceRepository, | ||
| reportRepo documentreport.DocumentReportRepository, | ||
| jobRepo uploadjob.UploadJobRepository, | ||
| userRepo user.UserRepository, | ||
| auditRepo auditlog.AuditLogRepository, | ||
| s3Storage *filestorage.S3FileStorage, | ||
| ) *DocumentService { | ||
| return &DocumentService{ | ||
| docRepo: docRepo, | ||
| fileRepo: fileRepo, | ||
| chunkRepo: chunkRepo, | ||
| chapterRepo: chapterRepo, | ||
| subjectRepo: subjectRepo, | ||
| termRepo: termRepo, | ||
| typeRepo: typeRepo, | ||
| langRepo: langRepo, | ||
| sourceRepo: sourceRepo, | ||
| reportRepo: reportRepo, | ||
| jobRepo: jobRepo, | ||
| userRepo: userRepo, | ||
| auditRepo: auditRepo, | ||
| s3Storage: s3Storage, | ||
| } | ||
| } | ||
|
|
||
| // Slug & Helper Logic | ||
|
|
||
| func BuildSlug(title string) string { | ||
| normalized := strings.ToLower(strings.TrimSpace(title)) | ||
| var sb strings.Builder | ||
| for i := 0; i < len(normalized); i++ { | ||
| ch := normalized[i] | ||
| if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') { | ||
| sb.WriteByte(ch) | ||
| } else if sb.Len() > 0 && sb.String()[sb.Len()-1] != '-' { | ||
| sb.WriteByte('-') | ||
| } | ||
| } | ||
| slug := strings.Trim(sb.String(), "-") | ||
| if slug == "" { | ||
| return "document" | ||
| } | ||
| return slug | ||
| } | ||
|
|
||
| func BuildShortCode() string { | ||
| bytes := make([]byte, 3) | ||
| _, _ = crand.Read(bytes) | ||
| return hex.EncodeToString(bytes) | ||
| } | ||
|
|
||
| func (s *DocumentService) EnsureUniqueSlug(ctx context.Context, baseSlug string) (string, error) { | ||
| for { | ||
| candidate := fmt.Sprintf("%s-%s", baseSlug, BuildShortCode()) | ||
| existing, err := s.docRepo.FindBySlug(ctx, candidate) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| if existing == nil { | ||
| return candidate, nil | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Core Document API | ||
|
|
||
| func (s *DocumentService) CreateDocument(ctx context.Context, input DocumentCreateInput, fileHeaderSize int64, fileReader io.Reader) (*DocumentCreateResultDto, error) { | ||
| // MD5 Hash computation | ||
| hasher := md5.New() | ||
| if _, err := io.Copy(hasher, fileReader); err != nil { | ||
| return nil, fmt.Errorf("failed to compute file md5: %w", err) | ||
| } | ||
| md5Hash := hex.EncodeToString(hasher.Sum(nil)) | ||
|
|
||
| // Check duplicates | ||
| exists, err := s.docRepo.ExistsByMd5(ctx, md5Hash) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if exists { | ||
| return nil, errors.New("Tài liệu này đã tồn tại trong hệ thống (file trùng lặp). Vui lòng kiểm tra lại.") | ||
| } | ||
|
|
||
| slugBase := BuildSlug(input.Title) | ||
| slug, err := s.EnsureUniqueSlug(ctx, slugBase) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| vis := "school_wide" | ||
| if input.Visibility != nil { | ||
| vis = *input.Visibility | ||
| } | ||
|
|
||
| doc := &document.Document{ | ||
| ID: uuid.New(), | ||
| OwnerUserID: input.OwnerUserID, | ||
| Title: input.Title, | ||
| Slug: &slug, | ||
| Description: input.Description, | ||
| SubjectID: input.SubjectID, | ||
| DocumentTypeID: input.DocumentTypeID, | ||
| AcademicTermID: input.AcademicTermID, | ||
| LanguageID: input.LanguageID, | ||
| Visibility: vis, | ||
| DocumentSourceID: input.DocumentSourceID, | ||
| Status: document.StatusProcessing, | ||
| TotalChunks: 0, | ||
| TotalChapters: 0, | ||
| ViewCount: 0, | ||
| DownloadCount: 0, | ||
| Md5Hash: &md5Hash, | ||
| CreatedAt: time.Now(), | ||
| UpdatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.docRepo.Create(ctx, doc); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &DocumentCreateResultDto{ | ||
| ID: doc.ID, | ||
| Slug: slug, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UploadOriginalFileToS3(ctx context.Context, docID uuid.UUID, reader io.Reader, filename string, contentType string) (string, string, error) { | ||
| key := fmt.Sprintf("%s/%s", docID.String(), filename) | ||
| urlStr, err := s.s3Storage.Save(ctx, key, reader, contentType) | ||
| if err != nil { | ||
| return "", "", err | ||
| } | ||
| return key, urlStr, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) EnqueueUploadJob(ctx context.Context, ownerUserID uuid.UUID, docID uuid.UUID, fileName string, s3Key string, fileSize int64) error { | ||
| msg := "Đang chờ xử lý" | ||
| job := &uploadjob.UploadJob{ | ||
| ID: uuid.New(), | ||
| OwnerUserID: ownerUserID, | ||
| DocumentID: &docID, | ||
| FileName: fileName, | ||
| StoragePath: &s3Key, | ||
| FileSizeBytes: fileSize, | ||
| Status: "pending", | ||
| ProgressPercent: 0, | ||
| Message: &msg, | ||
| IsNotified: false, | ||
| CreatedAt: time.Now(), | ||
| UpdatedAt: time.Now(), | ||
| } | ||
| return s.jobRepo.Create(ctx, job) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDocumentDetails(ctx context.Context, docID uuid.UUID, chunkPage, chunkPageSize int, incrementViewCount bool) (*DocumentDetailsDto, error) { | ||
| doc, err := s.docRepo.FindByID(ctx, docID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if doc == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| files, err := s.fileRepo.FindByDocumentID(ctx, docID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| chapters, err := s.chapterRepo.FindByDocumentID(ctx, docID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| chunks, err := s.chunkRepo.FindByDocumentID(ctx, docID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| // Clamp pagination | ||
| if chunkPageSize < 8 || chunkPageSize > 10 { | ||
| chunkPageSize = 10 | ||
| } | ||
| totalChunks := len(chunks) | ||
| totalPages := int(math.Ceil(float64(totalChunks) / float64(chunkPageSize))) | ||
| if totalPages < 1 { | ||
| totalPages = 1 | ||
| } | ||
| if chunkPage < 1 { | ||
| chunkPage = 1 | ||
| } | ||
| if chunkPage > totalPages { | ||
| chunkPage = totalPages | ||
| } | ||
|
|
||
| startIndex := (chunkPage - 1) * chunkPageSize | ||
| endIndex := startIndex + chunkPageSize | ||
| if endIndex > totalChunks { | ||
| endIndex = totalChunks | ||
| } | ||
|
|
||
| var pageChunks []*chunk.Chunk | ||
| if startIndex < totalChunks { | ||
| pageChunks = chunks[startIndex:endIndex] | ||
| } | ||
|
|
||
| // Increment view count if first page load | ||
| if incrementViewCount && chunkPage == 1 { | ||
| doc.ViewCount++ | ||
| doc.UpdatedAt = time.Now() | ||
| _ = s.docRepo.Update(ctx, doc) | ||
| } | ||
|
|
||
| // Maps DTOs | ||
| var filesDto []DocumentFileDto | ||
| for _, f := range files { | ||
| filesDto = append(filesDto, DocumentFileDto{ | ||
| ID: f.ID, | ||
| DocumentID: f.DocumentID, | ||
| OriginalFilename: f.OriginalFilename, | ||
| StoragePath: f.StoragePath, | ||
| S3Key: f.S3Key, | ||
| FileUrl: f.FileUrl, | ||
| MimeType: f.MimeType, | ||
| FileSizeBytes: f.FileSizeBytes, | ||
| PageCount: f.PageCount, | ||
| ExtractionStatus: f.ExtractionStatus, | ||
| CreatedAt: f.CreatedAt, | ||
| }) | ||
| } | ||
|
|
||
| var chaptersDto []DocumentChapterDto | ||
| for _, c := range chapters { | ||
| chaptersDto = append(chaptersDto, DocumentChapterDto{ | ||
| ID: c.ID, | ||
| DocumentID: c.DocumentID, | ||
| ParentChapterID: c.ParentChapterID, | ||
| Title: c.Title, | ||
| Summary: c.Summary, | ||
| ChapterOrder: c.ChapterOrder, | ||
| StartPage: c.StartPage, | ||
| EndPage: c.EndPage, | ||
| StartChunkIndex: c.StartChunkIndex, | ||
| EndChunkIndex: c.EndChunkIndex, | ||
| IsAiGenerated: c.IsAIGenerated, | ||
| ConfidenceScore: c.ConfidenceScore, | ||
| CreatedAt: c.CreatedAt, | ||
| }) | ||
| } | ||
|
|
||
| var chunksDto []DocumentChunkDto | ||
| for _, ch := range pageChunks { | ||
| hashVal := "" | ||
| if ch.ChunkHash != nil { | ||
| hashVal = *ch.ChunkHash | ||
| } | ||
| chunksDto = append(chunksDto, DocumentChunkDto{ | ||
| ID: ch.ID, | ||
| DocumentID: ch.DocumentID, | ||
| ChapterID: ch.ChapterID, | ||
| ChunkOrder: ch.ChunkOrder, | ||
| PageNumber: ch.PageNumber, | ||
| Content: ch.Content, | ||
| ContentTokens: ch.ContentTokens, | ||
| Metadata: ch.Metadata, | ||
| ChunkHash: hashVal, | ||
| HasEmbedding: len(ch.Embedding) > 0, | ||
| CreatedAt: ch.CreatedAt, | ||
| }) | ||
| } | ||
|
|
||
| return &DocumentDetailsDto{ | ||
| ID: doc.ID, | ||
| OwnerUserID: doc.OwnerUserID, | ||
| Title: doc.Title, | ||
| SubjectID: doc.SubjectID, | ||
| SubjectName: doc.SubjectName, | ||
| SubjectCode: doc.SubjectCode, | ||
| DocumentTypeID: doc.DocumentTypeID, | ||
| DocumentTypeName: doc.DocumentTypeName, | ||
| AcademicTermName: doc.AcademicTermName, | ||
| AcademicTermID: doc.AcademicTermID, | ||
| DocumentSourceID: doc.DocumentSourceID, | ||
| DocumentSourceName: doc.DocumentSourceName, | ||
| Visibility: doc.Visibility, | ||
| LanguageID: doc.LanguageID, | ||
| LanguageCode: doc.LanguageCode, | ||
| LanguageName: doc.LanguageName, | ||
| Description: doc.Description, | ||
| Status: doc.Status, | ||
| TotalChunks: doc.TotalChunks, | ||
| TotalChapters: doc.TotalChapters, | ||
| ViewCount: doc.ViewCount, | ||
| DownloadCount: doc.DownloadCount, | ||
| ApprovedAt: doc.ApprovedAt, | ||
| FileCount: len(files), | ||
| Files: filesDto, | ||
| Chapters: chaptersDto, | ||
| Chunks: chunksDto, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDocumentDetailsBySlug(ctx context.Context, slug string, requesterUserID *uuid.UUID, chunkPage, chunkPageSize int, incrementViewCount bool, isAdmin bool) (*DocumentDetailsDto, error) { | ||
| doc, err := s.docRepo.FindBySlug(ctx, slug) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if doc == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| // Visibility verification | ||
| if !isAdmin { | ||
| if doc.Visibility == "private" { | ||
| if requesterUserID == nil || *requesterUserID != doc.OwnerUserID { | ||
| return nil, errors.New("truy cập bị từ chối") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return s.GetDocumentDetails(ctx, doc.ID, chunkPage, chunkPageSize, incrementViewCount) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetOwnedDocumentDetailsBySlug(ctx context.Context, slug string, ownerUserID uuid.UUID) (*DocumentDetailsDto, error) { | ||
| doc, err := s.docRepo.FindOwnedBySlug(ctx, slug, ownerUserID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if doc == nil { | ||
| return nil, nil | ||
| } | ||
| return s.GetDocumentDetails(ctx, doc.ID, 1, 10, false) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetMyDocuments(ctx context.Context, ownerUserID uuid.UUID, query *string, subjectID *uuid.UUID, termID *uuid.UUID, sortBy *string, typeID *uuid.UUID, langID *uuid.UUID, sourceID *uuid.UUID, page, pageSize int) (*MyDocumentsDto, error) { | ||
| if pageSize < 6 || pageSize > 12 { | ||
| pageSize = 6 | ||
| } | ||
| if page < 1 { | ||
| page = 1 | ||
| } | ||
|
|
||
| params := document.FilterParams{ | ||
| Query: query, | ||
| SubjectID: subjectID, | ||
| AcademicTermID: termID, | ||
| DocumentTypeID: typeID, | ||
| LanguageID: langID, | ||
| DocumentSourceID: sourceID, | ||
| SortBy: sortBy, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| } | ||
|
|
||
| docs, total, err := s.docRepo.FindAllOwned(ctx, ownerUserID, params) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| totalPages := int(math.Ceil(float64(total) / float64(pageSize))) | ||
| if totalPages < 1 { | ||
| totalPages = 1 | ||
| } | ||
|
|
||
| activeJobs, err := s.jobRepo.FindActiveByOwner(ctx, ownerUserID) | ||
| if err != nil { | ||
| activeJobs = nil | ||
| } | ||
|
|
||
| var documentsList []DocumentListItemDto | ||
| for _, d := range docs { | ||
| preview := "" | ||
| if d.Description != nil { | ||
| preview = *d.Description | ||
| } | ||
| documentsList = append(documentsList, DocumentListItemDto{ | ||
| ID: d.ID, | ||
| Slug: *d.Slug, | ||
| Title: d.Title, | ||
| SubjectID: d.SubjectID, | ||
| SubjectName: d.SubjectName, | ||
| SubjectCode: d.SubjectCode, | ||
| DocumentTypeID: d.DocumentTypeID, | ||
| DocumentTypeName: d.DocumentTypeName, | ||
| AcademicTermName: d.AcademicTermName, | ||
| Status: d.Status, | ||
| Visibility: d.Visibility, | ||
| CreatedAt: d.CreatedAt, | ||
| UpdatedAt: d.UpdatedAt, | ||
| FileCount: 0, // Managed by detail query or counted later | ||
| ChunkCount: d.TotalChunks, | ||
| PreviewText: preview, | ||
| ViewCount: d.ViewCount, | ||
| }) | ||
| } | ||
|
|
||
| var jobsDto []UploadJobSummaryDto | ||
| for _, j := range activeJobs { | ||
| jobsDto = append(jobsDto, UploadJobSummaryDto{ | ||
| ID: j.ID, | ||
| DocumentID: j.DocumentID, | ||
| FileName: j.FileName, | ||
| FileSizeBytes: j.FileSizeBytes, | ||
| Status: j.Status, | ||
| ProgressPercent: j.ProgressPercent, | ||
| Message: j.Message, | ||
| CreatedAt: j.CreatedAt, | ||
| UpdatedAt: j.UpdatedAt, | ||
| }) | ||
| } | ||
|
|
||
| pending, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "pending") | ||
| approved, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "approved") | ||
| rejected, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "rejected") | ||
| totalFiles, _ := s.docRepo.CountFilesByOwner(ctx, ownerUserID) | ||
| totalChunks, _ := s.docRepo.CountChunksByOwner(ctx, ownerUserID) | ||
|
|
||
| return &MyDocumentsDto{ | ||
| Documents: documentsList, | ||
| TotalDocuments: total, | ||
| PendingDocuments: pending, | ||
| ApprovedDocuments: approved, | ||
| RejectedDocuments: rejected, | ||
| TotalFiles: totalFiles, | ||
| TotalChunks: totalChunks, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| TotalPages: totalPages, | ||
| ActiveUploadJobs: jobsDto, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetAllDocuments(ctx context.Context, query *string, subjectID *uuid.UUID, page, pageSize int, requesterUserID *uuid.UUID, sortBy *string, typeID *uuid.UUID, langID *uuid.UUID, sourceID *uuid.UUID) (*MyDocumentsDto, error) { | ||
| if pageSize < 6 || pageSize > 12 { | ||
| pageSize = 6 | ||
| } | ||
| if page < 1 { | ||
| page = 1 | ||
| } | ||
|
|
||
| params := document.FilterParams{ | ||
| Query: query, | ||
| SubjectID: subjectID, | ||
| DocumentTypeID: typeID, | ||
| LanguageID: langID, | ||
| DocumentSourceID: sourceID, | ||
| SortBy: sortBy, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| } | ||
|
|
||
| docs, total, err := s.docRepo.FindAllPublic(ctx, params, requesterUserID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| totalPages := int(math.Ceil(float64(total) / float64(pageSize))) | ||
| if totalPages < 1 { | ||
| totalPages = 1 | ||
| } | ||
|
|
||
| var documentsList []DocumentListItemDto | ||
| for _, d := range docs { | ||
| preview := "" | ||
| if d.Description != nil { | ||
| preview = *d.Description | ||
| } | ||
| documentsList = append(documentsList, DocumentListItemDto{ | ||
| ID: d.ID, | ||
| Slug: *d.Slug, | ||
| Title: d.Title, | ||
| SubjectID: d.SubjectID, | ||
| SubjectName: d.SubjectName, | ||
| SubjectCode: d.SubjectCode, | ||
| DocumentTypeID: d.DocumentTypeID, | ||
| DocumentTypeName: d.DocumentTypeName, | ||
| AcademicTermName: d.AcademicTermName, | ||
| Status: d.Status, | ||
| Visibility: d.Visibility, | ||
| CreatedAt: d.CreatedAt, | ||
| UpdatedAt: d.UpdatedAt, | ||
| ChunkCount: d.TotalChunks, | ||
| PreviewText: preview, | ||
| OwnerEmail: d.OwnerEmail, | ||
| ViewCount: d.ViewCount, | ||
| }) | ||
| } | ||
|
|
||
| return &MyDocumentsDto{ | ||
| Documents: documentsList, | ||
| TotalDocuments: total, | ||
| PendingDocuments: 0, | ||
| ApprovedDocuments: 0, | ||
| RejectedDocuments: 0, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| TotalPages: totalPages, | ||
| ActiveUploadJobs: []UploadJobSummaryDto{}, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetActiveUploadJobs(ctx context.Context, ownerUserID uuid.UUID) ([]*UploadJobSummaryDto, error) { | ||
| jobs, err := s.jobRepo.FindActiveByOwner(ctx, ownerUserID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var res []*UploadJobSummaryDto | ||
| for _, j := range jobs { | ||
| res = append(res, &UploadJobSummaryDto{ | ||
| ID: j.ID, | ||
| DocumentID: j.DocumentID, | ||
| FileName: j.FileName, | ||
| FileSizeBytes: j.FileSizeBytes, | ||
| Status: j.Status, | ||
| ProgressPercent: j.ProgressPercent, | ||
| Message: j.Message, | ||
| CreatedAt: j.CreatedAt, | ||
| UpdatedAt: j.UpdatedAt, | ||
| }) | ||
| } | ||
| return res, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDeleteDocumentViewDataBySlug(ctx context.Context, slug string, ownerUserID uuid.UUID) (*DeleteDocumentViewData, error) { | ||
| doc, err := s.docRepo.FindOwnedBySlug(ctx, slug, ownerUserID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if doc == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| fileCount, _ := s.docRepo.CountFilesByDocument(ctx, doc.ID) | ||
| chunkCount, _ := s.docRepo.CountChunksByDocument(ctx, doc.ID) | ||
|
|
||
| return &DeleteDocumentViewData{ | ||
| ID: doc.ID, | ||
| Title: doc.Title, | ||
| FileCount: fileCount, | ||
| ChunkCount: chunkCount, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteDocument(ctx context.Context, docID uuid.UUID) error { | ||
| doc, err := s.docRepo.FindByID(ctx, docID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if doc == nil { | ||
| return errors.New("document not found") | ||
| } | ||
|
|
||
| // Delete from Upload Jobs | ||
| _ = s.jobRepo.DeleteByDocumentID(ctx, docID) | ||
|
|
||
| // S3 Asset cleanup | ||
| files, err := s.fileRepo.FindByDocumentID(ctx, docID) | ||
| if err == nil { | ||
| for _, f := range files { | ||
| key := f.StoragePath | ||
| if f.S3Key != nil && *f.S3Key != "" { | ||
| key = *f.S3Key | ||
| } | ||
| if key != "" { | ||
| _ = s.s3Storage.Delete(ctx, key) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Clean references | ||
| _ = s.fileRepo.DeleteByDocumentID(ctx, docID) | ||
| _ = s.chunkRepo.DeleteByDocumentID(ctx, docID) | ||
| _ = s.chapterRepo.DeleteByDocumentID(ctx, docID) | ||
| _ = s.reportRepo.DeleteByDocumentID(ctx, docID) | ||
|
|
||
| // Clean document | ||
| return s.docRepo.Delete(ctx, docID) | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateDocument(ctx context.Context, docID uuid.UUID, ownerUserID uuid.UUID, title string, description *string, subjectID, typeID, termID, langID, sourceID *uuid.UUID, visibility string) error { | ||
| doc, err := s.docRepo.FindByID(ctx, docID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if doc == nil { | ||
| return errors.New("document not found") | ||
| } | ||
|
|
||
| if doc.OwnerUserID != ownerUserID { | ||
| return errors.New("truy cập bị từ chối") | ||
| } | ||
|
|
||
| doc.Title = title | ||
| doc.Description = description | ||
| doc.SubjectID = subjectID | ||
| doc.DocumentTypeID = typeID | ||
| doc.AcademicTermID = termID | ||
| doc.LanguageID = langID | ||
| doc.DocumentSourceID = sourceID | ||
| doc.Visibility = visibility | ||
| doc.UpdatedAt = time.Now() | ||
|
|
||
| return s.docRepo.Update(ctx, doc) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDashboardSummary(ctx context.Context, ownerUserID uuid.UUID) (*DashboardSummaryDto, error) { | ||
| recentDocs, _, err := s.docRepo.FindAllOwned(ctx, ownerUserID, document.FilterParams{Page: 1, PageSize: 5}) | ||
| if err != nil { | ||
| recentDocs = nil | ||
| } | ||
|
|
||
| activeJobs, err := s.jobRepo.FindActiveByOwner(ctx, ownerUserID) | ||
| if err != nil { | ||
| activeJobs = nil | ||
| } | ||
|
|
||
| var completedMessage *string | ||
| for _, j := range activeJobs { | ||
| if j.Status == "done" { | ||
| msg := fmt.Sprintf("Tệp \"%s\" đã xử lý xong.", j.FileName) | ||
| completedMessage = &msg | ||
| break | ||
| } | ||
| } | ||
|
|
||
| var recentDocsDto []DashboardRecentDocumentDto | ||
| for _, d := range recentDocs { | ||
| subName := d.SubjectName | ||
| recentDocsDto = append(recentDocsDto, DashboardRecentDocumentDto{ | ||
| ID: d.ID, | ||
| Slug: *d.Slug, | ||
| Title: d.Title, | ||
| Subject: subName, | ||
| Status: d.Status, | ||
| UpdatedAt: d.UpdatedAt, | ||
| FileCount: 0, // file repo details or left 0 | ||
| ChunkCount: d.TotalChunks, | ||
| }) | ||
| } | ||
|
|
||
| var activeJobsDto []UploadJobSummaryDto | ||
| for _, j := range activeJobs { | ||
| activeJobsDto = append(activeJobsDto, UploadJobSummaryDto{ | ||
| ID: j.ID, | ||
| DocumentID: j.DocumentID, | ||
| FileName: j.FileName, | ||
| FileSizeBytes: j.FileSizeBytes, | ||
| Status: j.Status, | ||
| ProgressPercent: j.ProgressPercent, | ||
| Message: j.Message, | ||
| CreatedAt: j.CreatedAt, | ||
| UpdatedAt: j.UpdatedAt, | ||
| }) | ||
| } | ||
|
|
||
| totalDocs, totalCount, _ := s.docRepo.FindAllOwned(ctx, ownerUserID, document.FilterParams{Page: 1, PageSize: 1}) | ||
| if totalDocs == nil { | ||
| totalCount = 0 | ||
| } | ||
|
|
||
| pending, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "pending") | ||
| approved, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "approved") | ||
| rejected, _ := s.docRepo.CountByStatus(ctx, ownerUserID, "rejected") | ||
| totalFiles, _ := s.docRepo.CountFilesByOwner(ctx, ownerUserID) | ||
| totalChunks, _ := s.docRepo.CountChunksByOwner(ctx, ownerUserID) | ||
|
|
||
| return &DashboardSummaryDto{ | ||
| TotalDocuments: totalCount, | ||
| TotalChunks: totalChunks, | ||
| TotalFiles: totalFiles, | ||
| ApprovedDocuments: approved, | ||
| PendingDocuments: pending, | ||
| RejectedDocuments: rejected, | ||
| RecentDocuments: recentDocsDto, | ||
| ActiveUploadJobs: activeJobsDto, | ||
| CompletedUploadMessage: completedMessage, | ||
| }, nil | ||
| } | ||
|
|
||
| // Metadata CRUD Implementation | ||
|
|
||
| func (s *DocumentService) GetSubjects(ctx context.Context) ([]*SubjectDto, error) { | ||
| subs, err := s.subjectRepo.FindAll(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*SubjectDto | ||
| for _, sub := range subs { | ||
| dtos = append(dtos, &SubjectDto{ | ||
| ID: sub.ID, | ||
| Code: sub.Code, | ||
| Name: sub.Name, | ||
| AcademicTermID: sub.AcademicTermID, | ||
| CreatedAt: sub.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetSubjectsByOwner(ctx context.Context, ownerUserID uuid.UUID) ([]*SubjectDto, error) { | ||
| subs, err := s.subjectRepo.FindAllByOwner(ctx, ownerUserID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*SubjectDto | ||
| for _, sub := range subs { | ||
| dtos = append(dtos, &SubjectDto{ | ||
| ID: sub.ID, | ||
| Code: sub.Code, | ||
| Name: sub.Name, | ||
| AcademicTermID: sub.AcademicTermID, | ||
| CreatedAt: sub.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) CreateSubject(ctx context.Context, code, name string, termID *uuid.UUID) (*SubjectDto, error) { | ||
| if code == "" || name == "" { | ||
| return nil, errors.New("Mã môn học và tên môn học không được để trống") | ||
| } | ||
| normCode := strings.ToUpper(strings.TrimSpace(code)) | ||
|
|
||
| // Check existing | ||
| all, _ := s.subjectRepo.FindAll(ctx) | ||
| for _, sub := range all { | ||
| if strings.EqualFold(sub.Code, normCode) { | ||
| return nil, errors.New("Mã môn học đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| sub := &subject.Subject{ | ||
| ID: uuid.New(), | ||
| Code: normCode, | ||
| Name: strings.TrimSpace(name), | ||
| AcademicTermID: termID, | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.subjectRepo.Create(ctx, sub); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &SubjectDto{ | ||
| ID: sub.ID, | ||
| Code: sub.Code, | ||
| Name: sub.Name, | ||
| AcademicTermID: sub.AcademicTermID, | ||
| CreatedAt: sub.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateSubject(ctx context.Context, id uuid.UUID, code, name string, termID *uuid.UUID) (*SubjectDto, error) { | ||
| if code == "" || name == "" { | ||
| return nil, errors.New("Mã môn học và tên môn học không được để trống") | ||
| } | ||
| normCode := strings.ToUpper(strings.TrimSpace(code)) | ||
|
|
||
| sub, err := s.subjectRepo.FindByID(ctx, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if sub == nil { | ||
| return nil, errors.New("không tìm thấy môn học") | ||
| } | ||
|
|
||
| all, _ := s.subjectRepo.FindAll(ctx) | ||
| for _, item := range all { | ||
| if item.ID != id && strings.EqualFold(item.Code, normCode) { | ||
| return nil, errors.New("Mã môn học đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| sub.Code = normCode | ||
| sub.Name = strings.TrimSpace(name) | ||
| sub.AcademicTermID = termID | ||
|
|
||
| if err := s.subjectRepo.Update(ctx, sub); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &SubjectDto{ | ||
| ID: sub.ID, | ||
| Code: sub.Code, | ||
| Name: sub.Name, | ||
| AcademicTermID: sub.AcademicTermID, | ||
| CreatedAt: sub.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteSubject(ctx context.Context, id uuid.UUID) error { | ||
| return s.subjectRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDocumentTypes(ctx context.Context) ([]*DocumentTypeDto, error) { | ||
| types, err := s.typeRepo.FindAll(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*DocumentTypeDto | ||
| for _, t := range types { | ||
| dtos = append(dtos, &DocumentTypeDto{ | ||
| ID: t.ID, | ||
| Name: t.Name, | ||
| Description: t.Description, | ||
| CreatedAt: t.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) CreateDocumentType(ctx context.Context, name string, description *string) (*DocumentTypeDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên loại học liệu không được để trống") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| all, _ := s.typeRepo.FindAll(ctx) | ||
| for _, t := range all { | ||
| if strings.EqualFold(t.Name, trimmed) { | ||
| return nil, errors.New("Tên loại học liệu đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| dt := &documenttype.DocumentType{ | ||
| ID: uuid.New(), | ||
| Name: trimmed, | ||
| Description: description, | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.typeRepo.Create(ctx, dt); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &DocumentTypeDto{ | ||
| ID: dt.ID, | ||
| Name: dt.Name, | ||
| Description: dt.Description, | ||
| CreatedAt: dt.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateDocumentType(ctx context.Context, id uuid.UUID, name string, description *string) (*DocumentTypeDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên loại học liệu không được để trống") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| dt, err := s.typeRepo.FindByID(ctx, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if dt == nil { | ||
| return nil, errors.New("không tìm thấy loại học liệu") | ||
| } | ||
|
|
||
| all, _ := s.typeRepo.FindAll(ctx) | ||
| for _, t := range all { | ||
| if t.ID != id && strings.EqualFold(t.Name, trimmed) { | ||
| return nil, errors.New("Tên loại học liệu đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| dt.Name = trimmed | ||
| dt.Description = description | ||
|
|
||
| if err := s.typeRepo.Update(ctx, dt); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &DocumentTypeDto{ | ||
| ID: dt.ID, | ||
| Name: dt.Name, | ||
| Description: dt.Description, | ||
| CreatedAt: dt.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteDocumentType(ctx context.Context, id uuid.UUID) error { | ||
| return s.typeRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetLanguages(ctx context.Context) ([]*LanguageDto, error) { | ||
| langs, err := s.langRepo.FindAll(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*LanguageDto | ||
| for _, l := range langs { | ||
| dtos = append(dtos, &LanguageDto{ | ||
| ID: l.ID, | ||
| Code: l.Code, | ||
| Name: l.Name, | ||
| CreatedAt: l.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) CreateLanguage(ctx context.Context, code, name string) (*LanguageDto, error) { | ||
| if code == "" || name == "" { | ||
| return nil, errors.New("Mã ngôn ngữ và tên ngôn ngữ không được để trống") | ||
| } | ||
| normCode := strings.ToLower(strings.TrimSpace(code)) | ||
| trimmedName := strings.TrimSpace(name) | ||
|
|
||
| all, _ := s.langRepo.FindAll(ctx) | ||
| for _, l := range all { | ||
| if strings.EqualFold(l.Code, normCode) { | ||
| return nil, errors.New("Mã ngôn ngữ đã tồn tại trong hệ thống") | ||
| } | ||
| if strings.EqualFold(l.Name, trimmedName) { | ||
| return nil, errors.New("Tên ngôn ngữ đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| l := &language.Language{ | ||
| ID: uuid.New(), | ||
| Code: normCode, | ||
| Name: trimmedName, | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.langRepo.Create(ctx, l); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &LanguageDto{ | ||
| ID: l.ID, | ||
| Code: l.Code, | ||
| Name: l.Name, | ||
| CreatedAt: l.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateLanguage(ctx context.Context, id uuid.UUID, code, name string) (*LanguageDto, error) { | ||
| if code == "" || name == "" { | ||
| return nil, errors.New("Mã ngôn ngữ và tên ngôn ngữ không được để trống") | ||
| } | ||
| normCode := strings.ToLower(strings.TrimSpace(code)) | ||
| trimmedName := strings.TrimSpace(name) | ||
|
|
||
| l, err := s.langRepo.FindByID(ctx, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if l == nil { | ||
| return nil, errors.New("không tìm thấy ngôn ngữ") | ||
| } | ||
|
|
||
| all, _ := s.langRepo.FindAll(ctx) | ||
| for _, item := range all { | ||
| if item.ID != id && strings.EqualFold(item.Code, normCode) { | ||
| return nil, errors.New("Mã ngôn ngữ đã tồn tại trong hệ thống") | ||
| } | ||
| if item.ID != id && strings.EqualFold(item.Name, trimmedName) { | ||
| return nil, errors.New("Tên ngôn ngữ đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| l.Code = normCode | ||
| l.Name = trimmedName | ||
|
|
||
| if err := s.langRepo.Update(ctx, l); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &LanguageDto{ | ||
| ID: l.ID, | ||
| Code: l.Code, | ||
| Name: l.Name, | ||
| CreatedAt: l.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteLanguage(ctx context.Context, id uuid.UUID) error { | ||
| return s.langRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetDocumentSources(ctx context.Context) ([]*DocumentSourceDto, error) { | ||
| sources, err := s.sourceRepo.FindAll(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*DocumentSourceDto | ||
| for _, src := range sources { | ||
| dtos = append(dtos, &DocumentSourceDto{ | ||
| ID: src.ID, | ||
| Name: src.Name, | ||
| CreatedAt: src.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) CreateDocumentSource(ctx context.Context, name string) (*DocumentSourceDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên nguồn tài liệu không được để trống") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| all, _ := s.sourceRepo.FindAll(ctx) | ||
| for _, s := range all { | ||
| if strings.EqualFold(s.Name, trimmed) { | ||
| return nil, errors.New("Tên nguồn tài liệu đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| src := &documentsource.DocumentSource{ | ||
| ID: uuid.New(), | ||
| Name: trimmed, | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.sourceRepo.Create(ctx, src); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &DocumentSourceDto{ | ||
| ID: src.ID, | ||
| Name: src.Name, | ||
| CreatedAt: src.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateDocumentSource(ctx context.Context, id uuid.UUID, name string) (*DocumentSourceDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên nguồn tài liệu không được để trống") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| src, err := s.sourceRepo.FindByID(ctx, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if src == nil { | ||
| return nil, errors.New("không tìm thấy nguồn tài liệu") | ||
| } | ||
|
|
||
| all, _ := s.sourceRepo.FindAll(ctx) | ||
| for _, s := range all { | ||
| if s.ID != id && strings.EqualFold(s.Name, trimmed) { | ||
| return nil, errors.New("Tên nguồn tài liệu đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| src.Name = trimmed | ||
|
|
||
| if err := s.sourceRepo.Update(ctx, src); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &DocumentSourceDto{ | ||
| ID: src.ID, | ||
| Name: src.Name, | ||
| CreatedAt: src.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteDocumentSource(ctx context.Context, id uuid.UUID) error { | ||
| return s.sourceRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetAcademicTerms(ctx context.Context) ([]*AcademicTermDto, error) { | ||
| terms, err := s.termRepo.FindAll(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var dtos []*AcademicTermDto | ||
| for _, term := range terms { | ||
| dtos = append(dtos, &AcademicTermDto{ | ||
| ID: term.ID, | ||
| Name: term.Name, | ||
| Order: term.Order, | ||
| CreatedAt: term.CreatedAt, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) CreateAcademicTerm(ctx context.Context, name string, order int) (*AcademicTermDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên học kỳ không được để trống") | ||
| } | ||
| if order < 0 { | ||
| return nil, errors.New("Thứ tự học kỳ phải lớn hơn hoặc bằng 0") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| all, _ := s.termRepo.FindAll(ctx) | ||
| for _, t := range all { | ||
| if strings.EqualFold(t.Name, trimmed) { | ||
| return nil, errors.New("Tên học kỳ đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| t := &academicterm.AcademicTerm{ | ||
| ID: uuid.New(), | ||
| Name: trimmed, | ||
| Order: order, | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.termRepo.Create(ctx, t); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &AcademicTermDto{ | ||
| ID: t.ID, | ||
| Name: t.Name, | ||
| Order: t.Order, | ||
| CreatedAt: t.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) UpdateAcademicTerm(ctx context.Context, id uuid.UUID, name string, order int) (*AcademicTermDto, error) { | ||
| if name == "" { | ||
| return nil, errors.New("Tên học kỳ không được để trống") | ||
| } | ||
| if order < 0 { | ||
| return nil, errors.New("Thứ tự học kỳ phải lớn hơn hoặc bằng 0") | ||
| } | ||
| trimmed := strings.TrimSpace(name) | ||
|
|
||
| t, err := s.termRepo.FindByID(ctx, id) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if t == nil { | ||
| return nil, errors.New("không tìm thấy học kỳ") | ||
| } | ||
|
|
||
| all, _ := s.termRepo.FindAll(ctx) | ||
| for _, item := range all { | ||
| if item.ID != id && strings.EqualFold(item.Name, trimmed) { | ||
| return nil, errors.New("Tên học kỳ đã tồn tại trong hệ thống") | ||
| } | ||
| } | ||
|
|
||
| t.Name = trimmed | ||
| t.Order = order | ||
|
|
||
| if err := s.termRepo.Update(ctx, t); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &AcademicTermDto{ | ||
| ID: t.ID, | ||
| Name: t.Name, | ||
| Order: t.Order, | ||
| CreatedAt: t.CreatedAt, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) DeleteAcademicTerm(ctx context.Context, id uuid.UUID) error { | ||
| return s.termRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| // Reports Implementation | ||
|
|
||
| func (s *DocumentService) ReportDocument(ctx context.Context, docID uuid.UUID, reporterUserID uuid.UUID, reason string) (*DocumentReportDto, error) { | ||
| if strings.TrimSpace(reason) == "" { | ||
| return nil, errors.New("Lý do báo cáo không được để trống") | ||
| } | ||
|
|
||
| doc, err := s.docRepo.FindByID(ctx, docID) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if doc == nil { | ||
| return nil, errors.New("tài liệu không tồn tại") | ||
| } | ||
|
|
||
| report := &documentreport.DocumentReport{ | ||
| ID: uuid.New(), | ||
| DocumentID: docID, | ||
| ReporterUserID: reporterUserID, | ||
| Reason: strings.TrimSpace(reason), | ||
| Status: "pending", | ||
| CreatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.reportRepo.Create(ctx, report); err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| title := doc.Title | ||
| slug := "" | ||
| if doc.Slug != nil { | ||
| slug = *doc.Slug | ||
| } | ||
|
|
||
| return &DocumentReportDto{ | ||
| ID: report.ID, | ||
| DocumentID: report.DocumentID, | ||
| ReporterUserID: report.ReporterUserID, | ||
| Reason: report.Reason, | ||
| Status: report.Status, | ||
| CreatedAt: report.CreatedAt, | ||
| DocumentTitle: &title, | ||
| DocumentSlug: &slug, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) GetPendingReports(ctx context.Context) ([]*DocumentReportDto, error) { | ||
| reports, err := s.reportRepo.FindPending(ctx) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var dtos []*DocumentReportDto | ||
| for _, r := range reports { | ||
| dtos = append(dtos, &DocumentReportDto{ | ||
| ID: r.ID, | ||
| DocumentID: r.DocumentID, | ||
| ReporterUserID: r.ReporterUserID, | ||
| Reason: r.Reason, | ||
| Status: r.Status, | ||
| CreatedAt: r.CreatedAt, | ||
| DocumentTitle: r.DocumentTitle, | ||
| DocumentSlug: r.DocumentSlug, | ||
| ReporterEmail: r.ReporterEmail, | ||
| }) | ||
| } | ||
| return dtos, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) ResolveReport(ctx context.Context, reportID uuid.UUID, action string) error { | ||
| report, err := s.reportRepo.FindByID(ctx, reportID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if report == nil { | ||
| return errors.New("báo cáo không tồn tại") | ||
| } | ||
|
|
||
| if strings.EqualFold(action, "delete") { | ||
| // Deletes target document | ||
| if err := s.DeleteDocument(ctx, report.DocumentID); err != nil { | ||
| return err | ||
| } | ||
| } else { | ||
| // Resolves all reports on this document | ||
| all, err := s.reportRepo.FindByDocumentID(ctx, report.DocumentID) | ||
| if err == nil { | ||
| for _, r := range all { | ||
| r.Status = "resolved" | ||
| _ = s.reportRepo.Update(ctx, r) | ||
| } | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // Admin Document & User Management | ||
|
|
||
| func (s *DocumentService) GetAdminDocuments(ctx context.Context, query *string, subjectID *uuid.UUID, page, pageSize int) (*MyDocumentsDto, error) { | ||
| if pageSize < 5 || pageSize > 100 { | ||
| pageSize = 10 | ||
| } | ||
| if page < 1 { | ||
| page = 1 | ||
| } | ||
|
|
||
| params := document.FilterParams{ | ||
| Query: query, | ||
| SubjectID: subjectID, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| } | ||
|
|
||
| docs, total, err := s.docRepo.FindAllAdmin(ctx, params) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| totalPages := int(math.Ceil(float64(total) / float64(pageSize))) | ||
| if totalPages < 1 { | ||
| totalPages = 1 | ||
| } | ||
|
|
||
| var documentsList []DocumentListItemDto | ||
| for _, d := range docs { | ||
| preview := "" | ||
| if d.Description != nil { | ||
| preview = *d.Description | ||
| } | ||
| documentsList = append(documentsList, DocumentListItemDto{ | ||
| ID: d.ID, | ||
| Slug: *d.Slug, | ||
| Title: d.Title, | ||
| SubjectID: d.SubjectID, | ||
| SubjectName: d.SubjectName, | ||
| SubjectCode: d.SubjectCode, | ||
| DocumentTypeID: d.DocumentTypeID, | ||
| DocumentTypeName: d.DocumentTypeName, | ||
| AcademicTermName: d.AcademicTermName, | ||
| Status: d.Status, | ||
| Visibility: d.Visibility, | ||
| CreatedAt: d.CreatedAt, | ||
| UpdatedAt: d.UpdatedAt, | ||
| ChunkCount: d.TotalChunks, | ||
| PreviewText: preview, | ||
| OwnerEmail: d.OwnerEmail, | ||
| ViewCount: d.ViewCount, | ||
| }) | ||
| } | ||
|
|
||
| return &MyDocumentsDto{ | ||
| Documents: documentsList, | ||
| TotalDocuments: total, | ||
| PendingDocuments: 0, | ||
| ApprovedDocuments: 0, | ||
| RejectedDocuments: 0, | ||
| Page: page, | ||
| PageSize: pageSize, | ||
| TotalPages: totalPages, | ||
| ActiveUploadJobs: []UploadJobSummaryDto{}, | ||
| }, nil | ||
| } | ||
|
|
||
| func (s *DocumentService) ApproveOrRejectDocument(ctx context.Context, docID uuid.UUID, approve bool) error { | ||
| doc, err := s.docRepo.FindByID(ctx, docID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if doc == nil { | ||
| return errors.New("tài liệu không tồn tại") | ||
| } | ||
|
|
||
| now := time.Now() | ||
| if approve { | ||
| doc.Status = "approved" | ||
| doc.ApprovedAt = &now | ||
| } else { | ||
| doc.Status = "rejected" | ||
| doc.ApprovedAt = nil | ||
| } | ||
| doc.UpdatedAt = now | ||
|
|
||
| return s.docRepo.Update(ctx, doc) | ||
| } | ||
|
|
||
| func (s *DocumentService) BlockOrUnblockUser(ctx context.Context, userID uuid.UUID, block bool) error { | ||
| u, err := s.userRepo.FindByID(ctx, userID) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if u == nil { | ||
| return errors.New("không tìm thấy người dùng") | ||
| } | ||
|
|
||
| u.IsBlocked = block | ||
| u.IsActive = !block | ||
| return s.userRepo.Update(ctx, u) | ||
| } | ||
|
|
||
| func (s *DocumentService) GetUsers(ctx context.Context) ([]*user.User, error) { | ||
| return s.userRepo.FindAll(ctx) | ||
| } |
There was a problem hiding this comment.
3. document_service.go is a god file 📘 Rule violation ⚙ Maintainability
backend/go/internal/application/document_service.go is far over 200 lines, concentrating many responsibilities into one file. This makes review, testing, and maintenance significantly harder.
Agent Prompt
## Issue description
A newly added Go file exceeds the 200-line limit and acts as a god file, accumulating multiple responsibilities.
## Issue Context
`backend/go/internal/application/document_service.go` is ~1659 lines long.
## Fix Focus Areas
- backend/go/internal/application/document_service.go[1-1659]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| func (h *DocumentHandler) GetMetadataLookups(c *gin.Context) { | ||
| subjects, _ := h.service.GetSubjects(c.Request.Context()) | ||
| types, _ := h.service.GetDocumentTypes(c.Request.Context()) | ||
| langs, _ := h.service.GetLanguages(c.Request.Context()) | ||
| sources, _ := h.service.GetDocumentSources(c.Request.Context()) | ||
| terms, _ := h.service.GetAcademicTerms(c.Request.Context()) | ||
|
|
There was a problem hiding this comment.
4. getmetadatalookups() ignores errors 📘 Rule violation ☼ Reliability
GetMetadataLookups() discards service errors (using _) and always returns 200, which can silently hide failures and return incomplete/incorrect data. Errors should be surfaced with meaningful messages and appropriate status codes.
Agent Prompt
## Issue description
The handler ignores errors returned from the service layer, which can lead to silent failures and hard-to-debug production issues.
## Issue Context
In `GetMetadataLookups`, multiple calls assign the error to `_`.
## Fix Focus Areas
- backend/go/internal/interface/handler/document-handler.go[594-600]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| package handler | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "swd392-chatbot-rag/internal/application" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
| "github.com/google/uuid" | ||
| ) | ||
|
|
||
| type DocumentHandler struct { | ||
| service *application.DocumentService | ||
| } | ||
|
|
||
| func NewDocumentHandler(service *application.DocumentService) *DocumentHandler { | ||
| return &DocumentHandler{ | ||
| service: service, | ||
| } | ||
| } | ||
|
|
||
| type ReportInput struct { | ||
| Reason string `json:"reason" binding:"required"` | ||
| } | ||
|
|
||
| // List godoc | ||
| // @Summary List public documents | ||
| // @Description Get a paginated list of all public documents with optional filtering | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Param q query string false "Search query" | ||
| // @Param subjectId query string false "Filter by subject ID (UUID)" | ||
| // @Param documentTypeId query string false "Filter by document type ID (UUID)" | ||
| // @Param languageId query string false "Filter by language ID (UUID)" | ||
| // @Param documentSourceId query string false "Filter by source ID (UUID)" | ||
| // @Param sortBy query string false "Sort order (date_desc, date_asc, title_asc, title_desc, views_asc, views_desc)" | ||
| // @Param page query int false "Page number (default 1)" | ||
| // @Param pageSize query int false "Page size (default 6)" | ||
| // @Success 200 {object} application.MyDocumentsDto | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents [get] | ||
| func (h *DocumentHandler) List(c *gin.Context) { | ||
| q := c.Query("q") | ||
| var queryPtr *string | ||
| if q != "" { | ||
| queryPtr = &q | ||
| } | ||
|
|
||
| var subjectIDPtr *uuid.UUID | ||
| if subIDStr := c.Query("subjectId"); subIDStr != "" { | ||
| if subID, err := uuid.Parse(subIDStr); err == nil { | ||
| subjectIDPtr = &subID | ||
| } | ||
| } | ||
|
|
||
| var typeIDPtr *uuid.UUID | ||
| if typeIDStr := c.Query("documentTypeId"); typeIDStr != "" { | ||
| if typeID, err := uuid.Parse(typeIDStr); err == nil { | ||
| typeIDPtr = &typeID | ||
| } | ||
| } | ||
|
|
||
| var langIDPtr *uuid.UUID | ||
| if langIDStr := c.Query("languageId"); langIDStr != "" { | ||
| if langID, err := uuid.Parse(langIDStr); err == nil { | ||
| langIDPtr = &langID | ||
| } | ||
| } | ||
|
|
||
| var sourceIDPtr *uuid.UUID | ||
| if sourceIDStr := c.Query("documentSourceId"); sourceIDStr != "" { | ||
| if sourceID, err := uuid.Parse(sourceIDStr); err == nil { | ||
| sourceIDPtr = &sourceID | ||
| } | ||
| } | ||
|
|
||
| sortBy := c.DefaultQuery("sortBy", "date_desc") | ||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) | ||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "6")) | ||
|
|
||
| // Requester ID (optional if public) | ||
| var requesterIDPtr *uuid.UUID | ||
| if userIDVal, exists := c.Get("user_id"); exists { | ||
| uid := userIDVal.(uuid.UUID) | ||
| requesterIDPtr = &uid | ||
| } | ||
|
|
||
| result, err := h.service.GetAllDocuments(c.Request.Context(), queryPtr, subjectIDPtr, page, pageSize, requesterIDPtr, &sortBy, typeIDPtr, langIDPtr, sourceIDPtr) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents: " + err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, result) | ||
| } | ||
|
|
||
| // MyDocuments godoc | ||
| // @Summary List owned documents | ||
| // @Description Get a list of documents owned by the logged-in lecturer | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Param q query string false "Search query" | ||
| // @Param subjectId query string false "Filter by subject ID (UUID)" | ||
| // @Param termId query string false "Filter by term ID (UUID)" | ||
| // @Param documentTypeId query string false "Filter by document type ID (UUID)" | ||
| // @Param languageId query string false "Filter by language ID (UUID)" | ||
| // @Param documentSourceId query string false "Filter by source ID (UUID)" | ||
| // @Param sortBy query string false "Sort order" | ||
| // @Param page query int false "Page number" | ||
| // @Param pageSize query int false "Page size" | ||
| // @Success 200 {object} application.MyDocumentsDto | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents/my [get] | ||
| func (h *DocumentHandler) MyDocuments(c *gin.Context) { | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| q := c.Query("q") | ||
| var queryPtr *string | ||
| if q != "" { | ||
| queryPtr = &q | ||
| } | ||
|
|
||
| var subjectIDPtr *uuid.UUID | ||
| if subIDStr := c.Query("subjectId"); subIDStr != "" { | ||
| if subID, err := uuid.Parse(subIDStr); err == nil { | ||
| subjectIDPtr = &subID | ||
| } | ||
| } | ||
|
|
||
| var termIDPtr *uuid.UUID | ||
| if termIDStr := c.Query("termId"); termIDStr != "" { | ||
| if termID, err := uuid.Parse(termIDStr); err == nil { | ||
| termIDPtr = &termID | ||
| } | ||
| } | ||
|
|
||
| var typeIDPtr *uuid.UUID | ||
| if typeIDStr := c.Query("documentTypeId"); typeIDStr != "" { | ||
| if typeID, err := uuid.Parse(typeIDStr); err == nil { | ||
| typeIDPtr = &typeID | ||
| } | ||
| } | ||
|
|
||
| var langIDPtr *uuid.UUID | ||
| if langIDStr := c.Query("languageId"); langIDStr != "" { | ||
| if langID, err := uuid.Parse(langIDStr); err == nil { | ||
| langIDPtr = &langID | ||
| } | ||
| } | ||
|
|
||
| var sourceIDPtr *uuid.UUID | ||
| if sourceIDStr := c.Query("documentSourceId"); sourceIDStr != "" { | ||
| if sourceID, err := uuid.Parse(sourceIDStr); err == nil { | ||
| sourceIDPtr = &sourceID | ||
| } | ||
| } | ||
|
|
||
| sortBy := c.DefaultQuery("sortBy", "date_desc") | ||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) | ||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "6")) | ||
|
|
||
| result, err := h.service.GetMyDocuments(c.Request.Context(), userID, queryPtr, subjectIDPtr, termIDPtr, &sortBy, typeIDPtr, langIDPtr, sourceIDPtr, page, pageSize) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch my documents: " + err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, result) | ||
| } | ||
|
|
||
| // Upload godoc | ||
| // @Summary Upload document file | ||
| // @Description Upload a document file (PDF/DOC/DOCX/PPT/PPTX) and start indexing | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Accept multipart/form-data | ||
| // @Produce json | ||
| // @Param file formData file true "Document file" | ||
| // @Param title formData string false "Title" | ||
| // @Param description formData string false "Description" | ||
| // @Param subject_id formData string false "Subject ID (UUID)" | ||
| // @Param document_type_id formData string false "Document Type ID (UUID)" | ||
| // @Param academic_term_id formData string false "Academic Term ID (UUID)" | ||
| // @Param language_id formData string false "Language ID (UUID)" | ||
| // @Param document_source_id formData string false "Document Source ID (UUID)" | ||
| // @Param visibility formData string false "Visibility (public, school_wide, private)" | ||
| // @Success 201 {object} application.DocumentCreateResultDto | ||
| // @Failure 400 {object} map[string]string | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents/upload [post] | ||
| func (h *DocumentHandler) Upload(c *gin.Context) { | ||
| file, err := c.FormFile("file") | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"}) | ||
| return | ||
| } | ||
|
|
||
| importLog := func(format string, v ...interface{}) { | ||
| println(fmt.Sprintf("[UploadAPI] "+format, v...)) | ||
| } | ||
|
|
||
| importLog("Bắt đầu xử lý file: %s (%d bytes)", file.Filename, file.Size) | ||
|
|
||
| // Validate extension | ||
| ext := strings.ToLower(filepath.Ext(file.Filename)) | ||
| allowed := false | ||
| for _, a := range application.AllowedExtensions { | ||
| if a == ext { | ||
| allowed = true | ||
| break | ||
| } | ||
| } | ||
| if !allowed { | ||
| importLog("LỖI: Định dạng file %s không được hỗ trợ", ext) | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Chỉ hỗ trợ PDF, DOC, DOCX, PPT, PPTX"}) | ||
| return | ||
| } | ||
|
|
||
| title := c.PostForm("title") | ||
| if strings.TrimSpace(title) == "" { | ||
| title = file.Filename | ||
| } | ||
|
|
||
| description := c.PostForm("description") | ||
| var descPtr *string | ||
| if description != "" { | ||
| descPtr = &description | ||
| } | ||
|
|
||
| var subjectID *uuid.UUID | ||
| if subIDStr := c.PostForm("subject_id"); subIDStr != "" { | ||
| if uid, err := uuid.Parse(subIDStr); err == nil { | ||
| subjectID = &uid | ||
| } | ||
| } | ||
|
|
||
| var typeID *uuid.UUID | ||
| if typeIDStr := c.PostForm("document_type_id"); typeIDStr != "" { | ||
| if uid, err := uuid.Parse(typeIDStr); err == nil { | ||
| typeID = &uid | ||
| } | ||
| } | ||
|
|
||
| var termID *uuid.UUID | ||
| if termIDStr := c.PostForm("academic_term_id"); termIDStr != "" { | ||
| if uid, err := uuid.Parse(termIDStr); err == nil { | ||
| termID = &uid | ||
| } | ||
| } | ||
|
|
||
| var langID *uuid.UUID | ||
| if langIDStr := c.PostForm("language_id"); langIDStr != "" { | ||
| if uid, err := uuid.Parse(langIDStr); err == nil { | ||
| langID = &uid | ||
| } | ||
| } | ||
|
|
||
| var sourceID *uuid.UUID | ||
| if sourceIDStr := c.PostForm("document_source_id"); sourceIDStr != "" { | ||
| if uid, err := uuid.Parse(sourceIDStr); err == nil { | ||
| sourceID = &uid | ||
| } | ||
| } | ||
|
|
||
| visibility := c.DefaultPostForm("visibility", "school_wide") | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| src, err := file.Open() | ||
| if err != nil { | ||
| importLog("LỖI: Không thể mở file reader: %v", err) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) | ||
| return | ||
| } | ||
| defer src.Close() | ||
|
|
||
| input := application.DocumentCreateInput{ | ||
| Title: title, | ||
| Description: descPtr, | ||
| SubjectID: subjectID, | ||
| DocumentTypeID: typeID, | ||
| AcademicTermID: termID, | ||
| LanguageID: langID, | ||
| Visibility: &visibility, | ||
| DocumentSourceID: sourceID, | ||
| OwnerUserID: userID, | ||
| } | ||
|
|
||
| importLog("1. Đang tính MD5 và tạo bản ghi tài liệu trong Database...") | ||
| saved, err := h.service.CreateDocument(c.Request.Context(), input, file.Size, src) | ||
| if err != nil { | ||
| importLog("LỖI khi tạo bản ghi tài liệu: %v", err) | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
| importLog("✓ Đã tạo bản ghi tài liệu thành công. ID: %s, Slug: %s", saved.ID, saved.Slug) | ||
|
|
||
| // Re-open for upload to S3 | ||
| freshSrc, err := file.Open() | ||
| if err != nil { | ||
| importLog("LỖI: Không thể mở lại file để upload S3: %v", err) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file for S3 upload"}) | ||
| return | ||
| } | ||
| defer freshSrc.Close() | ||
|
|
||
| importLog("2. Đang thực hiện upload file gốc lên AWS S3 (Bucket: %s)...", "aws-prn222-bucket") | ||
| contentType := file.Header.Get("Content-Type") | ||
| if contentType == "" { | ||
| contentType = "application/octet-stream" | ||
| } | ||
| s3Key, _, err := h.service.UploadOriginalFileToS3(c.Request.Context(), saved.ID, freshSrc, file.Filename, contentType) | ||
| if err != nil { | ||
| importLog("LỖI khi upload lên AWS S3: %v", err) | ||
| // Clean up the created document if upload fails | ||
| _ = h.service.DeleteDocument(c.Request.Context(), saved.ID) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "S3 upload failed: " + err.Error()}) | ||
| return | ||
| } | ||
| importLog("✓ Upload AWS S3 thành công. S3 Key: %s", s3Key) | ||
|
|
||
| importLog("3. Đang đưa tác vụ chạy ngầm (Upload Job) vào hàng đợi...") | ||
| err = h.service.EnqueueUploadJob(c.Request.Context(), userID, saved.ID, file.Filename, s3Key, file.Size) | ||
| if err != nil { | ||
| importLog("LỖI khi đưa Job chạy ngầm vào DB: %v", err) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue background job: " + err.Error()}) | ||
| return | ||
| } | ||
| importLog("✓ Đã đưa tác vụ chạy ngầm vào hàng đợi thành công.") | ||
|
|
||
| c.JSON(http.StatusCreated, saved) | ||
| } | ||
|
|
||
| // Details godoc | ||
| // @Summary Get document details by slug | ||
| // @Description Get detailed information of a document including files, chapters, and paginated chunks | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Param slug path string true "Document Slug" | ||
| // @Param chunkPage query int false "Chunk page number (default 1)" | ||
| // @Param chunkPageSize query int false "Chunk page size (default 10, range 8-10)" | ||
| // @Success 200 {object} application.DocumentDetailsDto | ||
| // @Failure 403 {object} map[string]string | ||
| // @Failure 404 {object} map[string]string | ||
| // @Router /api/documents/{slug} [get] | ||
| func (h *DocumentHandler) Details(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| chunkPage, _ := strconv.Atoi(c.DefaultQuery("chunkPage", "1")) | ||
| chunkPageSize, _ := strconv.Atoi(c.DefaultQuery("chunkPageSize", "10")) | ||
|
|
||
| var requesterIDPtr *uuid.UUID | ||
| if userIDVal, exists := c.Get("user_id"); exists { | ||
| uid := userIDVal.(uuid.UUID) | ||
| requesterIDPtr = &uid | ||
| } | ||
|
|
||
| roleIDVal, roleExists := c.Get("role_id") | ||
| isAdmin := false | ||
| if roleExists && roleIDVal.(int16) == 1 { | ||
| isAdmin = true | ||
| } | ||
|
|
||
| details, err := h.service.GetDocumentDetailsBySlug(c.Request.Context(), slug, requesterIDPtr, chunkPage, chunkPageSize, chunkPage == 1, isAdmin) | ||
| if err != nil { | ||
| c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
| if details == nil { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, details) | ||
| } | ||
|
|
||
| // Edit godoc | ||
| // @Summary Edit document details | ||
| // @Description Update metadata info of a document owned by the lecturer | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Accept json | ||
| // @Produce json | ||
| // @Param slug path string true "Document Slug" | ||
| // @Param body body application.DocumentEditInput true "Edit details" | ||
| // @Success 200 {object} map[string]string | ||
| // @Failure 400 {object} map[string]string | ||
| // @Router /api/documents/{slug}/edit [post] | ||
| func (h *DocumentHandler) Edit(c *gin.Context) { | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| var input struct { | ||
| ID string `json:"id" binding:"required"` | ||
| Title string `json:"title" binding:"required"` | ||
| Description *string `json:"description"` | ||
| SubjectID *string `json:"subject_id"` | ||
| DocumentTypeID *string `json:"document_type_id"` | ||
| AcademicTermID *string `json:"academic_term_id"` | ||
| LanguageID *string `json:"language_id"` | ||
| Visibility string `json:"visibility" binding:"required"` | ||
| DocumentSourceID *string `json:"document_source_id"` | ||
| } | ||
|
|
||
| if err := c.ShouldBindJSON(&input); err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| docID, err := uuid.Parse(input.ID) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) | ||
| return | ||
| } | ||
|
|
||
| var subjectID *uuid.UUID | ||
| if input.SubjectID != nil && *input.SubjectID != "" { | ||
| if uid, err := uuid.Parse(*input.SubjectID); err == nil { | ||
| subjectID = &uid | ||
| } | ||
| } | ||
|
|
||
| var typeID *uuid.UUID | ||
| if input.DocumentTypeID != nil && *input.DocumentTypeID != "" { | ||
| if uid, err := uuid.Parse(*input.DocumentTypeID); err == nil { | ||
| typeID = &uid | ||
| } | ||
| } | ||
|
|
||
| var termID *uuid.UUID | ||
| if input.AcademicTermID != nil && *input.AcademicTermID != "" { | ||
| if uid, err := uuid.Parse(*input.AcademicTermID); err == nil { | ||
| termID = &uid | ||
| } | ||
| } | ||
|
|
||
| var langID *uuid.UUID | ||
| if input.LanguageID != nil && *input.LanguageID != "" { | ||
| if uid, err := uuid.Parse(*input.LanguageID); err == nil { | ||
| langID = &uid | ||
| } | ||
| } | ||
|
|
||
| var sourceID *uuid.UUID | ||
| if input.DocumentSourceID != nil && *input.DocumentSourceID != "" { | ||
| if uid, err := uuid.Parse(*input.DocumentSourceID); err == nil { | ||
| sourceID = &uid | ||
| } | ||
| } | ||
|
|
||
| err = h.service.UpdateDocument(c.Request.Context(), docID, userID, input.Title, input.Description, subjectID, typeID, termID, langID, sourceID, input.Visibility) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"}) | ||
| } | ||
|
|
||
| // Delete godoc | ||
| // @Summary Delete document | ||
| // @Description Delete document from database and S3 (only owner lecturer can delete) | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Param slug path string true "Document Slug" | ||
| // @Success 200 {object} map[string]string | ||
| // @Failure 404 {object} map[string]string | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents/{slug}/delete [post] | ||
| func (h *DocumentHandler) Delete(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| // Verify ownership | ||
| details, err := h.service.GetOwnedDocumentDetailsBySlug(c.Request.Context(), slug, userID) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
| if details == nil { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Document not found or access denied"}) | ||
| return | ||
| } | ||
|
|
||
| if err := h.service.DeleteDocument(c.Request.Context(), details.ID); err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete document: " + err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) | ||
| } | ||
|
|
||
| // DeleteViewData godoc | ||
| // @Summary Get delete document preview stats | ||
| // @Description View statistics of files and chunks that will be deleted prior to confirmation | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Param slug path string true "Document Slug" | ||
| // @Success 200 {object} application.DeleteDocumentViewData | ||
| // @Failure 404 {object} map[string]string | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents/{slug}/delete-view [get] | ||
| func (h *DocumentHandler) DeleteViewData(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| viewData, err := h.service.GetDeleteDocumentViewDataBySlug(c.Request.Context(), slug, userID) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
| if viewData == nil { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, viewData) | ||
| } | ||
|
|
||
| // Report godoc | ||
| // @Summary Report document violation | ||
| // @Description Send a violation report for a document | ||
| // @Tags documents | ||
| // @Security BearerAuth | ||
| // @Accept json | ||
| // @Produce json | ||
| // @Param slug path string true "Document Slug" | ||
| // @Param body body handler.ReportInput true "Report Reason" | ||
| // @Success 200 {object} application.DocumentReportDto | ||
| // @Failure 400 {object} map[string]string | ||
| // @Failure 404 {object} map[string]string | ||
| // @Router /api/documents/{slug}/report [post] | ||
| func (h *DocumentHandler) Report(c *gin.Context) { | ||
| slug := c.Param("slug") | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| var req ReportInput | ||
|
|
||
| if err := c.ShouldBindJSON(&req); err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| doc, err := h.service.GetDocumentDetailsBySlug(c.Request.Context(), slug, &userID, 1, 1, false, false) | ||
| if err != nil || doc == nil { | ||
| c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) | ||
| return | ||
| } | ||
|
|
||
| report, err := h.service.ReportDocument(c.Request.Context(), doc.ID, userID, req.Reason) | ||
| if err != nil { | ||
| c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, report) | ||
| } | ||
|
|
||
| // Dashboard godoc | ||
| // @Summary Lecturer dashboard statistics | ||
| // @Description Get document, file, and chunk count statistics for the logged-in lecturer | ||
| // @Tags lecturer | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Success 200 {object} application.DashboardSummaryDto | ||
| // @Failure 500 {object} map[string]string | ||
| // @Router /api/documents/dashboard [get] | ||
| func (h *DocumentHandler) Dashboard(c *gin.Context) { | ||
| userID := c.MustGet("user_id").(uuid.UUID) | ||
|
|
||
| summary, err := h.service.GetDashboardSummary(c.Request.Context(), userID) | ||
| if err != nil { | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||
| return | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, summary) | ||
| } | ||
|
|
||
| // GetMetadataLookups godoc | ||
| // @Summary Get metadata lookups | ||
| // @Description Get dropdown listings of academic terms, subjects, types, sources, languages | ||
| // @Tags metadata | ||
| // @Security BearerAuth | ||
| // @Produce json | ||
| // @Success 200 {object} map[string]interface{} | ||
| // @Router /api/documents/lookups [get] | ||
| func (h *DocumentHandler) GetMetadataLookups(c *gin.Context) { | ||
| subjects, _ := h.service.GetSubjects(c.Request.Context()) | ||
| types, _ := h.service.GetDocumentTypes(c.Request.Context()) | ||
| langs, _ := h.service.GetLanguages(c.Request.Context()) | ||
| sources, _ := h.service.GetDocumentSources(c.Request.Context()) | ||
| terms, _ := h.service.GetAcademicTerms(c.Request.Context()) | ||
|
|
||
| c.JSON(http.StatusOK, gin.H{ | ||
| "subjects": subjects, | ||
| "documentTypes": types, | ||
| "languages": langs, | ||
| "documentSources": sources, | ||
| "academicTerms": terms, | ||
| }) | ||
| } No newline at end of file |
There was a problem hiding this comment.
5. document-handler.go is a god file 📘 Rule violation ⚙ Maintainability
backend/go/internal/interface/handler/document-handler.go exceeds 200 lines, combining many endpoints and responsibilities into a single handler file. This reduces readability and increases change risk.
Agent Prompt
## Issue description
A newly added handler file exceeds the 200-line limit and contains many responsibilities/endpoints.
## Issue Context
`backend/go/internal/interface/handler/document-handler.go` is over 600 lines.
## Fix Focus Areas
- backend/go/internal/interface/handler/document-handler.go[1-608]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // split token for Better Auth | ||
| if parts := strings.SplitN(tokenString, ".", 2); len(parts) == 2 { | ||
| tokenString = parts[0] | ||
| } |
There was a problem hiding this comment.
6. Auth strips token signature 📘 Rule violation ⛨ Security
The auth middleware strips everything after the first . and validates only the remaining prefix against the DB, bypassing JWT signature validation semantics. This can allow accepting tampered tokens and violates the requirement to validate Better Auth JWTs on protected routes.
Agent Prompt
## Issue description
Protected routes must require valid JWTs and the Go backend must validate Better Auth tokens. Current middleware truncates tokens at the first `.` and does not verify JWT signatures/claims.
## Issue Context
`AuthMiddleware` does `strings.SplitN(tokenString, ".", 2)` and keeps only `parts[0]`.
## Fix Focus Areas
- backend/go/internal/interface/middleware/auth.go[39-53]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| vis := "school_wide" | ||
| if input.Visibility != nil { | ||
| vis = *input.Visibility | ||
| } | ||
|
|
||
| doc := &document.Document{ | ||
| ID: uuid.New(), | ||
| OwnerUserID: input.OwnerUserID, | ||
| Title: input.Title, | ||
| Slug: &slug, | ||
| Description: input.Description, | ||
| SubjectID: input.SubjectID, | ||
| DocumentTypeID: input.DocumentTypeID, | ||
| AcademicTermID: input.AcademicTermID, | ||
| LanguageID: input.LanguageID, | ||
| Visibility: vis, | ||
| DocumentSourceID: input.DocumentSourceID, | ||
| Status: document.StatusProcessing, | ||
| TotalChunks: 0, | ||
| TotalChapters: 0, | ||
| ViewCount: 0, | ||
| DownloadCount: 0, | ||
| Md5Hash: &md5Hash, | ||
| CreatedAt: time.Now(), | ||
| UpdatedAt: time.Now(), | ||
| } | ||
|
|
||
| if err := s.docRepo.Create(ctx, doc); err != nil { | ||
| return nil, err |
There was a problem hiding this comment.
7. Visibility string bypass 🐞 Bug ⛨ Security
Document visibility is accepted as an arbitrary string and access control only checks the exact literal "private"; values like "Private" or "private " bypass the private check and can also slip into public listings (SQL uses visibility <> 'private'). This can expose documents intended to be private.
Agent Prompt
## Issue description
Visibility is user-controlled and not validated. Because downstream checks compare only `== "private"` and SQL filters use `<> 'private'`, any non-exact variant bypasses privacy protections.
## Issue Context
Visibility flows from request form input -> `DocumentCreateInput.Visibility` -> persisted to `documents.visibility` -> later used for access checks and public listing filters.
## Fix
- Validate/normalize visibility at ingestion (handler or service): trim + lower-case, then allow only `{public, school_wide, private}`.
- Reject invalid values with 400.
- Consider using constants / iota-like enums and (optionally) a DB CHECK constraint.
- Update repository filters to be robust (e.g., `LOWER(TRIM(d.visibility)) <> 'private'`) if legacy data may already contain bad values.
## Fix Focus Areas
- backend/go/internal/interface/handler/document-handler.go[271-296]
- backend/go/internal/application/document_service.go[389-406]
- backend/go/internal/application/document_service.go[610-617]
- backend/go/internal/infrastructure/repository/postgres/document-repository.go[122-135]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| // CORS Middleware | ||
| r.Use(func(c *gin.Context) { | ||
| c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | ||
| c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") | ||
| c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") | ||
| c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") |
There was a problem hiding this comment.
8. Invalid cors credentials 🐞 Bug ⛨ Security
The Gin CORS middleware sets Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true, which is an invalid combination for browser CORS and will break credentialed cross-origin requests. This will cause auth/session calls from the frontend to fail in browsers.
Agent Prompt
## Issue description
CORS response headers currently allow any origin (`*`) while also enabling credentials. Browsers reject this combination, which breaks cross-origin requests when cookies/credentials are used.
## Issue Context
CORS is implemented manually in Gin middleware.
## Fix
- Replace `*` with a configured allowlist (e.g., from env/config) and echo back the requesting Origin only if allowed.
- If you truly want `*`, then set `Access-Control-Allow-Credentials` to `false`.
- Add `Vary: Origin` when dynamically setting the origin.
- Consider using `github.com/gin-contrib/cors` to avoid subtle mistakes.
## Fix Focus Areas
- backend/go/internal/interface/router/router.go[24-37]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Actionable comments posted: 1
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
docs/api_reference.md (1)
144-208:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRemove the documented admin surface or update the project rules first.
This section publishes
/api/admin/*endpoints and explicit role-based administration even though the repo rules say the system should not have an admin panel or user roles beyond basic user accounts. Shipping this contract now will push clients toward an unsupported scope.As per coding guidelines: "Do not implement complex user management - no admin panel or user roles beyond basic user accounts".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/api_reference.md` around lines 144 - 208, The admin API section ("API Dành cho Quản trị viên (Admin Only - role_id = 1)") currently documents /api/admin/* endpoints (e.g., /api/admin/users, /api/admin/documents, /api/admin/subjects, etc.) which contradicts the project rule forbidding an admin panel or role-based admin accounts; remove or revert this entire admin section from the docs OR update it to a non-public/internal note pending a project-rules change (for example delete the "API Dành cho Quản trị viên" header and all /api/admin/* routes or mark them explicitly as internal/unsupported and require a formal design decision before publishing). Ensure you reference the specific documented paths (/api/admin/users, /api/admin/documents, /api/admin/subjects, /api/admin/document-types, /api/admin/languages, /api/admin/document-sources, /api/admin/academic-terms) when making the change so no admin endpoints remain published.backend/start-backends.bat (1)
1-4:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winSave this batch file with CRLF line endings.
This script is Windows-only, and the current LF-only endings are already being flagged as a batch-parser compatibility risk. Please convert it to CRLF before merge.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/start-backends.bat` around lines 1 - 4, The batch script start-backends.bat currently uses LF-only line endings which can break Windows batch parsing; convert the file to CRLF line endings (Windows-style) before committing so the commands (including the echo and the npx concurrently invocation that launches "cd /d \"%~dp0better-auth\" && pnpm dev" and "cd /d \"%~dp0go\" && go run ./cmd/server") run correctly on Windows; ensure your editor or git core.autocrlf is set to produce CRLF for this file and re-save/re-commit start-backends.bat with CRLF endings.backend/go/internal/infrastructure/embedding/gemini-embedding.go (3)
150-176:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift3072-dim embeddings break the documented pgvector contract.
This now requests and enforces 3072 values, but the backend contract still requires Gemini Embedding 2 vectors to be stored and queried as 768-dim pgvector entries. Unless the schema, repositories, and similarity queries were migrated in the same change, this will break ingestion/retrieval at runtime.
Suggested fix
- OutputDimensionality: 3072, + OutputDimensionality: 768, @@ - if len(result.Embedding.Values) != 3072 { - return nil, fmt.Errorf("expected 3072 dimensions, got %d", len(result.Embedding.Values)) + if len(result.Embedding.Values) != 768 { + return nil, fmt.Errorf("expected 768 dimensions, got %d", len(result.Embedding.Values)) @@ - OutputDimensionality: 3072, + OutputDimensionality: 768, @@ - if len(emb.Values) != 3072 { - return nil, fmt.Errorf("embedding at index %d has %d dimensions, expected 3072", i, len(emb.Values)) + if len(emb.Values) != 768 { + return nil, fmt.Errorf("embedding at index %d has %d dimensions, expected 768", i, len(emb.Values)) }As per coding guidelines "Use pgvector with PostgreSQL for storing and querying document embeddings with 768 dimensions from Gemini Embedding 2".
Also applies to: 204-240
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/embedding/gemini-embedding.go` around lines 150 - 176, The code is requesting and validating 3072-dim embeddings which contradicts the pgvector/Postgres contract expecting 768-dim vectors; update the embedRequest.OutputDimensionality value and the validation check in gemini-embedding.go (the embedRequest construction and the length check against result.Embedding.Values) to use 768 instead of 3072, and make the same change for the other occurrence referenced (around lines 204-240) so ingestion and similarity queries remain compatible with the Gemini Embedding 2 / pgvector schema.
1-311: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftSplit this client into smaller focused files.
This file is already over 300 lines and mixes constructor defaults, request payload building, retry transport, rate limiting, and preprocessing. That violates the backend guideline for sub-200-line Go files with a single responsibility.
As per coding guidelines "Go backend files must be under 200 lines with focused, single responsibility".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/embedding/gemini-embedding.go` around lines 1 - 311, The file is too large and mixes responsibilities; split GeminiEmbeddingClient into focused files: move constructors and configuration (NewGeminiEmbeddingClient, NewGeminiEmbeddingClientWithConfig, parseAPIKeys, getNextKey, struct GeminiEmbeddingClient) into a config/constructor file; move request/response types and payload builders (embedRequest, batchEmbedRequest, embedResponse, batchEmbedResponse, errorResponse, and the JSON marshalling logic used in Embed and EmbedBatch) into a requests file; move HTTP behavior and retry logic (doRequest and any client/http.Transport setup) into a transport file; and move text handling (preprocessText and any validation) into a preprocessing file; update imports and references so Embed and EmbedBatch call the new helpers (payload builders, doRequest, preprocessText) and ensure each new file is under ~200 lines and keeps single responsibility.
249-300:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFix Gemini embedding retries reusing an exhausted request body
doRequestretries by callingreq.Clonebut never recreatesreq.Body. Since the initial request body is created frombytes.NewBuffer(jsonBody)andhttp.Client.Doconsumes it, later retry attempts can send an empty/EOF payload. Recreate the body fromreq.GetBody()for each attempt when available.Suggested fix
func (c *GeminiEmbeddingClient) doRequest(req *http.Request, result interface{}) error { var lastErr error var resp *http.Response for attempt := 0; attempt <= c.maxRetries; attempt++ { @@ - reqClone := req.Clone(req.Context()) + reqClone := req.Clone(req.Context()) + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return fmt.Errorf("failed to rebuild request body for retry: %w", err) + } + reqClone.Body = body + } resp, lastErr = c.client.Do(reqClone) if lastErr != nil { continue }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/embedding/gemini-embedding.go` around lines 249 - 300, The doRequest retry loop in GeminiEmbeddingClient reuses req.Clone(req.Context()) but doesn't reset req.Body, causing empty bodies on retries; modify doRequest to recreate req.Body before each attempt by calling the original request's GetBody (req.GetBody()) when non-nil and assigning its returned ReadCloser to reqClone.Body (and close it after use), falling back to copying a stored byte slice of the original payload if GetBody is nil; ensure this logic is used before c.client.Do(reqClone) in the loop (refer to doRequest, req.Clone, req.GetBody, c.client.Do, c.maxRetries) so each retry sends a fresh body and avoids EOFs.
🟡 Minor comments (6)
AGENTS.md-108-117 (1)
108-117:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd a language to the fenced architecture block.
This block is missing a fence language, which will keep markdownlint complaining.
textis enough here.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@AGENTS.md` around lines 108 - 117, The fenced code block showing the architecture diagram lacks a language tag which triggers markdownlint; update the triple-backtick fence before the diagram to include the language token "text" (i.e., change ``` to ```text) so the block becomes a fenced "text" code block; target the fenced architecture block that contains the lines starting with "Browser ──► Hono Backend..." and "Browser ──► Next.js..." and adjust the opening fence accordingly.backend/go/internal/infrastructure/repository/postgres/documentfile-repository.go-67-76 (1)
67-76:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
rows.Err()check after thefor rows.Next()loop inFindByDocumentID.After scanning rows and before returning
files, handle any iteration-time DB error viaif err := rows.Err(); err != nil { return nil, err }.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/repository/postgres/documentfile-repository.go` around lines 67 - 76, The rows iteration in FindByDocumentID does not check for iteration errors; after the for rows.Next() loop and before returning files, add an if err := rows.Err(); err != nil { return nil, err } check to surface any errors encountered during iteration (refer to the rows variable and the FindByDocumentID function where rows.Next() and rows.Scan(...) are used).backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go-88-97 (1)
88-97:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd a
rows.Err()check after therows.Next()loop inFindActiveByOwner(backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go).
rows.Next()/rows.Scan()errors during iteration won’t be surfaced unless you checkrows.Err()after the loop; return that error beforereturn jobs, nil.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go` around lines 88 - 97, The iteration over SQL rows in FindActiveByOwner currently only checks errors from rows.Scan() but not the iterator itself; after the for rows.Next() loop (and before returning jobs), call rows.Err() and if non-nil return that error (e.g., return nil, rows.Err()) so any deferred iteration errors are surfaced; update the function containing rows.Next()/rows.Scan() to perform this rows.Err() check.backend/go/internal/infrastructure/repository/postgres/language-repository.go-65-74 (1)
65-74:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPropagate
rowsiteration errors inLanguageRepository.FindAll
FindAllreturnslangs, nilafter thefor rows.Next()loop without checkingrows.Err(), which can silently drop scan/iteration failures—returnrows.Err()when non-nil.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/repository/postgres/language-repository.go` around lines 65 - 74, The FindAll implementation in LanguageRepository iterates rows with for rows.Next() but never checks rows.Err(), so scanning/iteration errors can be lost; after the loop in LanguageRepository.FindAll, call rows.Err() and if non-nil return nil and that error (or wrap it) instead of returning langs,nil, ensuring iteration errors from rows are propagated back to the caller.backend/go/internal/infrastructure/repository/postgres/chapter-repository.go-73-84 (1)
73-84:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winHandle terminal row-iteration errors after scanning loop.
FindByDocumentIDreturns without checkingrows.Err()after thefor rows.Next()loop (seebackend/go/internal/infrastructure/repository/postgres/chapter-repository.go, lines 73-84), so any late driver/stream error can be silently ignored. Add arows.Err()check before returning.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/repository/postgres/chapter-repository.go` around lines 73 - 84, FindByDocumentID currently iterates over rows but doesn't check for terminal iteration errors; after the for rows.Next() loop (in the FindByDocumentID implementation) call rows.Err() and if it returns a non-nil error return it (or wrap it) instead of returning chapters, ensuring any driver/stream errors are surfaced; keep the existing rows closing logic intact.backend/go/internal/infrastructure/repository/postgres/documentreport-repository.go-76-85 (1)
76-85:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
rows.Err()checks after therows.Next()loops inFindPendingandFindByDocumentID.Both methods currently return immediately after iteration; missing
if err := rows.Err(); err != nil { ... }can silently drop driver iteration errors.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/internal/infrastructure/repository/postgres/documentreport-repository.go` around lines 76 - 85, The iteration over SQL rows in FindPending and FindByDocumentID currently returns the accumulated reports without checking rows.Err(), which can silently ignore driver iteration errors; after the for rows.Next() { ... } loop in both FindPending and FindByDocumentID, add a check like if err := rows.Err(); err != nil { return nil, err } so any iteration error from rows is propagated (ensure you perform this check before returning reports and after the loop that populates reports).
🧹 Nitpick comments (1)
backend/go/pkg/config/env.go (1)
27-35: ⚡ Quick winValidate or remove unused
MAX_FILE_SIZEparsing inbackend/go/pkg/config/env.go.
parseIntsilently drops non-digits, butConfig.MAX_FILE_SIZEis only set inbackend/go/pkg/config/env.goand is not referenced anywhere else inbackend/go—file-size enforcement is done bybackend/go/internal/infrastructure/filestorage/local.go’s hardcodedMaxFileSize(50MB).- Either remove the unused
MAX_FILE_SIZEconfig/parser, or wirecfg.MAX_FILE_SIZEinto the upload/file-storage flow; if you enable env-driven sizing, switch tostrconv.ParseIntand propagate an explicitLoad()error with a meaningful message (e.g.,invalid MAX_FILE_SIZE).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/go/pkg/config/env.go` around lines 27 - 35, The project currently has an unused parseInt function and Config.MAX_FILE_SIZE; either remove the unused config and parseInt, or wire MAX_FILE_SIZE into the file-upload flow: replace parseInt with strconv.ParseInt when loading MAX_FILE_SIZE in the Config.Load() (propagate and return a clear error like "invalid MAX_FILE_SIZE" on parse failure), store the parsed value on Config.MAX_FILE_SIZE, and use that Config.MAX_FILE_SIZE instead of the hardcoded MaxFileSize constant in internal/infrastructure/filestorage/local.go (adjust local.go to accept the config value or pass it into the uploader initialization).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/go/internal/interface/middleware/auth.go`:
- Around line 39-53: The code currently truncates the credential via
strings.SplitN(tokenString, ".", 2) and only matches session.token in the DB
without any JWT signature or sub claim checks; instead, stop truncating the
tokenString, parse and verify the JWT signature using the BETTER_AUTH_SECRET
(e.g., via a JWT library in the auth middleware where tokenString is handled),
validate the token is not expired and extract the "sub" claim, then QueryRow
(db.QueryRow) to load the session/user and ensure the session belongs to the
same subject (compare JWT sub to the session's userId/returned userIdStr) and/or
that the session token corresponds to a signed JWT, returning unauthorized if
signature/sub validation fails. Ensure error handling logs/returns unauthorized
on missing/invalid BETTER_AUTH_SECRET or invalid JWT signature/claims.
---
Outside diff comments:
In `@backend/go/internal/infrastructure/embedding/gemini-embedding.go`:
- Around line 150-176: The code is requesting and validating 3072-dim embeddings
which contradicts the pgvector/Postgres contract expecting 768-dim vectors;
update the embedRequest.OutputDimensionality value and the validation check in
gemini-embedding.go (the embedRequest construction and the length check against
result.Embedding.Values) to use 768 instead of 3072, and make the same change
for the other occurrence referenced (around lines 204-240) so ingestion and
similarity queries remain compatible with the Gemini Embedding 2 / pgvector
schema.
- Around line 1-311: The file is too large and mixes responsibilities; split
GeminiEmbeddingClient into focused files: move constructors and configuration
(NewGeminiEmbeddingClient, NewGeminiEmbeddingClientWithConfig, parseAPIKeys,
getNextKey, struct GeminiEmbeddingClient) into a config/constructor file; move
request/response types and payload builders (embedRequest, batchEmbedRequest,
embedResponse, batchEmbedResponse, errorResponse, and the JSON marshalling logic
used in Embed and EmbedBatch) into a requests file; move HTTP behavior and retry
logic (doRequest and any client/http.Transport setup) into a transport file; and
move text handling (preprocessText and any validation) into a preprocessing
file; update imports and references so Embed and EmbedBatch call the new helpers
(payload builders, doRequest, preprocessText) and ensure each new file is under
~200 lines and keeps single responsibility.
- Around line 249-300: The doRequest retry loop in GeminiEmbeddingClient reuses
req.Clone(req.Context()) but doesn't reset req.Body, causing empty bodies on
retries; modify doRequest to recreate req.Body before each attempt by calling
the original request's GetBody (req.GetBody()) when non-nil and assigning its
returned ReadCloser to reqClone.Body (and close it after use), falling back to
copying a stored byte slice of the original payload if GetBody is nil; ensure
this logic is used before c.client.Do(reqClone) in the loop (refer to doRequest,
req.Clone, req.GetBody, c.client.Do, c.maxRetries) so each retry sends a fresh
body and avoids EOFs.
In `@backend/start-backends.bat`:
- Around line 1-4: The batch script start-backends.bat currently uses LF-only
line endings which can break Windows batch parsing; convert the file to CRLF
line endings (Windows-style) before committing so the commands (including the
echo and the npx concurrently invocation that launches "cd /d
\"%~dp0better-auth\" && pnpm dev" and "cd /d \"%~dp0go\" && go run
./cmd/server") run correctly on Windows; ensure your editor or git core.autocrlf
is set to produce CRLF for this file and re-save/re-commit start-backends.bat
with CRLF endings.
In `@docs/api_reference.md`:
- Around line 144-208: The admin API section ("API Dành cho Quản trị viên (Admin
Only - role_id = 1)") currently documents /api/admin/* endpoints (e.g.,
/api/admin/users, /api/admin/documents, /api/admin/subjects, etc.) which
contradicts the project rule forbidding an admin panel or role-based admin
accounts; remove or revert this entire admin section from the docs OR update it
to a non-public/internal note pending a project-rules change (for example delete
the "API Dành cho Quản trị viên" header and all /api/admin/* routes or mark them
explicitly as internal/unsupported and require a formal design decision before
publishing). Ensure you reference the specific documented paths
(/api/admin/users, /api/admin/documents, /api/admin/subjects,
/api/admin/document-types, /api/admin/languages, /api/admin/document-sources,
/api/admin/academic-terms) when making the change so no admin endpoints remain
published.
---
Major comments:
In `@backend/better-auth/auth.ts`:
- Around line 13-15: Check for and fail fast when required env vars are missing
before constructing the DB pool or auth config: validate
process.env.DATABASE_URL and process.env.BETTER_AUTH_SECRET at module init and
throw or exit with clear messages; update the code around the Pool creation (the
database: new Pool({...}) expression) and wherever BETTER_AUTH_SECRET is used to
return/throw an error like "missing DATABASE_URL" or "missing
BETTER_AUTH_SECRET" so startup fails with a meaningful message rather than
letting the Pool or auth library surface provider-specific errors.
- Around line 11-12: Replace the hardcoded defaults for baseURL and
trustedOrigins with environment-backed values: remove the
"http://localhost:5000" fallback for baseURL and stop using the
["http://localhost:3000"] literal for trustedOrigins; instead read
process.env.BETTER_AUTH_URL for baseURL and
process.env.BETTER_AUTH_TRUSTED_ORIGINS (comma-separated) for trustedOrigins,
parse/split the string into an array, and validate that these env vars are
present (throw or log a clear error from the same module if missing) so the auth
service is configured entirely from env; update any code referencing baseURL or
trustedOrigins in auth.ts to use the new env-derived values.
In `@backend/better-auth/index.ts`:
- Around line 12-17: Replace the hardcoded localhost CORS/Swagger settings by
reading allowed origins and the advertised server URL from environment variables
and reusing them: create a single constant (e.g., allowedOrigins) parsed from an
env var like ALLOWED_ORIGINS (comma-separated) and a constant
advertisedServerUrl from ADVERTISED_SERVER_URL, then use allowedOrigins in the
cors({ origin: ... , allowMethods..., credentials: true }) call and use
advertisedServerUrl for any Swagger/server advertisement; remove the hardcoded
"http://localhost:3000" and update the other spots referenced around lines 32-36
to reuse these constants so all config comes from env.
- Around line 205-210: The listener currently uses process.env.PORT to set port
which allows the auth service to run on non-5000 ports and breaks the frontend
contract; change the code so the service always binds to port 5000 by removing
the process.env.PORT parsing and hardcoding const port = 5000 (ensure the
console.log and the serve call continue to reference the same port variable),
leaving container-level port mappings to the environment instead of changing the
in-process listener; update references to process.env.PORT, the port variable,
and the serve({ fetch: app.fetch, port }) invocation accordingly.
In `@backend/go/cmd/server/main.go`:
- Around line 58-59: The code hardcodes the Gemini model ID and server listen
address; update initialization to read these from the existing cfg returned by
config.Load() instead of literals: add fields like GeminiModel (default
"gemini-2.5-flash") and ListenAddr (default ":8080") to the cfg struct, then
pass cfg.GeminiModel into segmentation.NewGeminiChapterSegmentationService and
use cfg.ListenAddr where the server is started (also replace any other literal
uses such as the lines creating embedding.NewEmbeddingClient and the server
Listen/Serve calls); ensure defaults are applied in config.Load() so env vars
override them.
In `@backend/go/internal/application/document_service.go`:
- Around line 33-41: AllowedExtensions and AllowedMimeTypes currently exclude
.txt and Markdown types so uploads are rejected; update AllowedExtensions to
include ".txt" and ".md" and add the corresponding MIME types ("text/plain" and
"text/markdown") to the AllowedMimeTypes map (ensuring keys match exact MIME
strings used elsewhere) so the DocumentService will accept and pass TXT/Markdown
files to parsing/indexing.
- Around line 663-714: The code is swallowing repository errors (e.g., in
s.jobRepo.FindActiveByOwner and
s.docRepo.CountByStatus/CountFilesByOwner/CountChunksByOwner) and returning
empty/nil results which hides DB failures; change these call sites in
document_service.go (the block that builds documentsList, jobsDto and collects
pending/approved/rejected/totalFiles/totalChunks) to propagate errors instead of
overriding them—return a wrapped error with context (e.g., "failed to fetch
active jobs" or "failed to count documents for owner") when any repo call
returns err, or implement the explicit partial-response error contract if
intended; ensure you modify the same pattern at the other noted locations
(around the blocks at ~831-839 and ~905-963) so all repository failures produce
meaningful error messages rather than silent defaults.
- Around line 473-503: GetDocumentDetails currently calls
chunkRepo.FindByDocumentID to load all chunks then slices a page, which forces
full hydrate (including embeddings via HasEmbedding); change this to ask the
repo for just the requested page without embeddings. Add/replace the call to
chunkRepo.FindByDocumentID with a new repository method (e.g.,
FindChunksPageByDocumentID or FindByDocumentIDWithPagination) that accepts
docID, chunkPage, chunkPageSize and a flag to exclude embeddings (or a separate
method like FindChunksPageNoEmbeddings), move the pagination/clamping logic
(pageSize clamp, page bounds, start/end calculation) into that repo method, and
have GetDocumentDetails call the new method to receive only the page-sized
[]*chunk.Chunk (no embeddings) instead of loading all chunks. Ensure method
names referenced: GetDocumentDetails, chunkRepo.FindByDocumentID (replace),
chunk.Chunk, and HasEmbedding to locate related logic.
- Around line 851-875: The DeleteDocument flow currently swallows errors from
jobRepo.DeleteByDocumentID, S3 deletions (s3Storage.Delete), and child-row
deletes (fileRepo.DeleteByDocumentID, chunkRepo.DeleteByDocumentID,
chapterRepo.DeleteByDocumentID, reportRepo.DeleteByDocumentID), which can leave
orphaned rows/objects — wrap the DB deletions in a single transaction and
surface any storage failures instead of ignoring them: begin a DB transaction
(use your repo/DB transaction helper), perform jobRepo.DeleteByDocumentID,
fileRepo.DeleteByDocumentID, chunkRepo.DeleteByDocumentID,
chapterRepo.DeleteByDocumentID and reportRepo.DeleteByDocumentID using the
transactional context (rollback on any error), collect and attempt S3 object
deletions from fileRepo.FindByDocumentID but return an error if any
s3Storage.Delete fails (or aggregate errors), and only call docRepo.Delete
(committing the transaction) after all child deletes and S3 deletes have
succeeded; do not discard returned errors from the listed methods.
In `@backend/go/internal/domain/academicterm/entity.go`:
- Around line 9-14: The AcademicTerm domain struct currently carries
transport/persistence concerns via `json` and `db` tags on the `AcademicTerm`
type; remove all struct tags from the `AcademicTerm` definition so the domain
entity has no DTO/storage annotations. Add separate DTO/repository models (e.g.,
`AcademicTermDTO` for handlers or `AcademicTermModel` for repo) with the
required `json`/`db` tags and implement mapping code in the handler or
repository layer (mapper functions) to convert between `AcademicTerm` and those
tagged types when persisting or serializing.
In `@backend/go/internal/domain/auditlog/repository.go`:
- Around line 7-10: The current AuditLogRepository interface exposes an
unbounded FindAll; change it to a paginated contract (e.g., replace FindAll with
a method like Find or List that accepts pagination parameters and returns a next
cursor). Update the interface signature on AuditLogRepository (for example:
Find(ctx context.Context, limit int, cursor string) ([]*AuditLog, nextCursor
string, error) or List(ctx context.Context, opts PageOptions) (logs []*AuditLog,
nextCursor string, err error)), ensure the AuditLog type is unchanged, and
update all repository/handler callers to pass/propagate the pagination
parameters and handle the returned nextCursor instead of relying on an unbounded
result set.
In `@backend/go/internal/domain/chapter/entity.go`:
- Around line 10-22: The Chapter domain entity currently contains
Postgres-specific `db` struct tags—remove all `db:"..."` tags from the domain
struct (the Chapter entity defined in entity.go) so the domain remains
infrastructure-agnostic, keep only domain-related tags like `json` if needed,
and create a separate storage/DTO type in the repository/infrastructure layer
(e.g., ChapterRecord or ChapterModel) that contains the `db` tags and any
DB-specific field shapes; implement explicit mapping functions (e.g.,
ToRecord/FromRecord or MapDomainToRepo/MapRepoToDomain) inside the repository to
translate between the domain Chapter and the DB DTO.
In `@backend/go/internal/domain/document/entity.go`:
- Around line 33-41: The Document entity currently contains projection/join
fields (SubjectName, DocumentTypeName, LanguageName, OwnerEmail) that couple
domain state to persistence/DTO concerns; remove these pointer fields from the
Document domain struct and relocate them into a separate read-model/DTO used by
the interface/infrastructure layers (e.g., DocumentDTO or DocumentView) returned
by repository queries or mappers; keep only true domain properties and
identifiers (e.g., SubjectCode, LanguageCode, DocumentSourceName as needed for
domain logic) in the Document entity and implement mapping logic in the
repository/mapper functions to populate the new DTO with joined values like
SubjectName, DocumentTypeName, LanguageName, and OwnerEmail.
In `@backend/go/internal/domain/subject/entity.go`:
- Around line 16-17: The Subject domain entity currently contains a
repository/join field AcademicTermName which couples the domain to a SQL shape;
remove the AcademicTermName *string field from the Subject struct in entity.go
and move any usage into a repository/DTO or view model returned by the
repository layer (e.g., a SubjectWithTermName or SubjectDTO) and update
callers/mappers that relied on Subject.AcademicTermName to consume the new DTO
instead; ensure JSON/db tags are removed from the domain struct and adjust
repository query code and mapping functions (and any constructors or tests
referencing AcademicTermName) to populate the new DTO rather than the domain
entity.
In `@backend/go/internal/domain/user/entity.go`:
- Around line 30-31: The RoleName field on the user entity is declared as a
non-nullable string but is scanned from a LEFT JOIN result (r.name) that can be
NULL; change RoleName to a nullable type (e.g., *string) in the struct (symbol:
RoleName) so pgx can scan NULL values, and update any callers that dereference
RoleName, or alternatively modify the SQL used by FindByID, FindByEmail, and
FindAll to coalesce the role name (COALESCE(r.name, '')) or convert the LEFT
JOIN to an INNER JOIN so NULLs cannot be returned; pick one approach and apply
it consistently across the struct and repository query/scan usage.
- Around line 9-31: The Role and User structs in entity.go contain `db` struct
tags which tie the domain layer to Postgres; remove all `db:"..."` tags from
Role and User (including field tags like `db:"id"`, `db:"email"`,
`db:"createdAt"`, `db:"role_name"`, etc.) and keep only domain-relevant tags
(e.g., `json`) so the domain model remains storage-agnostic; after removing the
`db` tags, ensure any database-specific mapping is moved to the infrastructure
layer (e.g., repository DTOs or mappers) that translate between the DB schema
and the domain types (for structs: Role and User and their fields like
CreatedAtBA, UpdatedAtBA, RoleID, RoleName, Image, Username, DisplayUsername).
In `@backend/go/internal/infrastructure/fileparser/pptx-parser.go`:
- Around line 114-141: The parser currently appends "\n" after every <a:t> run
(in the xml token loop that uses decoder and strings.Builder sb), which
fragments sentences; change to accumulate runs into a paragraph buffer and only
append a newline when the paragraph ends: create a current paragraph buffer
(e.g., cur strings.Builder) and on xml.StartElement where se.Name.Local == "t"
decode the text and append it to cur (add a single space if needed between
sibling runs instead of "\n"), then on xml.EndElement detect paragraph
boundaries (e.g., se.Name.Local == "p" or "p" == paragraph element used in PPTX,
or "a:p" local name "p") flush cur to sb with a single "\n" separator and reset
cur; keep other error handling the same.
In `@backend/go/internal/infrastructure/filestorage/s3.go`:
- Around line 31-67: Save currently returns an s3://bucket/key URI while
OpenRead and Delete expect a raw key; make the contract symmetric by normalizing
inputs in OpenRead and Delete to accept either raw keys or full s3:// URIs. In
both S3FileStorage.OpenRead and S3FileStorage.Delete, detect
strings.HasPrefix(key, "s3://"), strip that prefix, split the remainder into
bucket and object (e.g., strings.TrimPrefix(key, "s3://") then
strings.SplitN(..., "/", 2)), verify the bucket matches s.bucketName (or return
an error if it doesn't), and set key to the extracted object path before calling
s.client.GetObject / s.client.DeleteObject; keep Save returning the s3://...
URI.
In
`@backend/go/internal/infrastructure/repository/postgres/academicterm-repository.go`:
- Around line 76-93: The Update and Delete methods on AcademicTermRepository
currently ignore the result of r.pool.Exec and thus return nil even when no rows
were affected; modify AcademicTermRepository.Update and
AcademicTermRepository.Delete to inspect the ExecResult.RowsAffected(ctx) (or
RowsAffected() on the returned result), and if it reports 0 rows affected,
return a typed not-found error (e.g., a package-level ErrNotFound or an
academicterm.ErrNotFound) instead of nil; otherwise return the original exec
error or nil on success.
In `@backend/go/internal/infrastructure/repository/postgres/chunk-repository.go`:
- Around line 75-88: The loop over rows in chunk-repository.go (where you
iterate using rows.Next(), scanning into ch *chunk.Chunk and pgvector.Vector) is
missing a post-loop check for rows.Err(), so add a check immediately after the
for rows.Next() loop: call if err := rows.Err(); err != nil { return nil, err }
to surface any iteration/scan errors instead of returning potentially partial
chunks; keep existing behavior of building chunks (ch.Embedding =
vector.Slice(), append to chunks) unchanged and return chunks only when
rows.Err() is nil.
- Around line 36-59: ChunkRepository.CreateBatch currently uses r.pool.SendBatch
directly which is not transactional; wrap the batch in an explicit transaction
so all inserts are atomic: call r.pool.Begin(ctx) to get a tx, use
tx.SendBatch(...) instead of r.pool.SendBatch, ensure you Close the batch reader
(br) and call tx.Rollback(ctx) on any error path and tx.Commit(ctx) only after
all br.Exec() calls succeed; update error handling around br.Exec / br.Close to
rollback the tx and return the error if any operation fails.
In
`@backend/go/internal/infrastructure/repository/postgres/document-repository.go`:
- Around line 128-135: The SQL in document-repository.go hardcodes role gating
by filtering WHERE d.status = 'completed' AND (u.role_id = 1 OR u.role_id = 2)
AND d.visibility <> 'private'; remove the role check "(u.role_id = 1 OR
u.role_id = 2)" from that WHERE clause so public document queries only filter on
d.status = 'completed' and d.visibility <> 'private' (keep the JOINs to
users/subjects/etc. intact); update any query-building helper or method that
constructs this SQL (the query string containing joins on users u and the WHERE
clause) and run related tests to ensure no other code expects the role filter.
- Around line 125-135: The public listing query in FindAllPublic is exposing PII
by selecting u.email as owner_email; remove owner_email from the SELECT (and
drop or keep-but-not-use the JOIN users u if not needed) so the SQL no longer
projects uploader emails, and update the corresponding row scan/destination
mapping in the same FindAllPublic implementation so it doesn't expect an
owner_email column (adjust any columns slice or struct population code to match
the new projection).
- Around line 15-486: This file groups too many responsibilities in
DocumentRepository; split methods into focused files so each file stays under
~200 lines. Create separate files (e.g., document_repository_crud.go containing
NewDocumentRepository, Create, FindByID, FindBySlug, FindOwnedBySlug, Update,
Delete, ExistsByMd5), document_repository_public.go for FindAllPublic and
FindAllOwned, document_repository_admin.go for FindAllAdmin, and
document_repository_counters.go for CountByStatus, CountFilesByOwner,
CountChunksByOwner, CountFilesByDocument, CountChunksByDocument; ensure each
file keeps the same receiver (r *DocumentRepository) and imports/ package remain
consistent and run go vet/go fmt after moving.
- Around line 226-239: The loops that iterate over sql rows (using rows.Next())
in the document repository list methods are missing a post-iteration check for
rows.Err(), so iterator/read errors can be swallowed; after each Next() loop
(e.g., the loop building docs with var docs []*document.Document and scanning
into doc) add a check like if err := rows.Err(); err != nil { return /* match
original return types */ nil, 0, err } (or return nil, err/count as appropriate)
before returning results so any deferred iterator error is propagated; apply the
same pattern to the other two list methods that use rows.Next().
In
`@backend/go/internal/infrastructure/repository/postgres/documentsource-repository.go`:
- Around line 23-24: The repository is hardcoding timeouts in multiple methods
(calls to context.WithTimeout(ctx, 5*time.Second) and 10*time.Second); update
DocumentSourceRepository to accept a configurable timeout value (inject from the
shared config/env) and replace those literals with the injected field (e.g.,
r.queryTimeout or r.cfg.DBQueryTimeout) in all places that call
context.WithTimeout (locations around the existing context.WithTimeout usages).
Ensure the repository constructor stores the config timeout(s) and that each
method uses that field instead of hardcoded durations so timeouts come from
environment-backed config.
- Around line 22-31: The repository methods (e.g.,
DocumentSourceRepository.Create) currently return raw pgx driver errors; update
each method to wrap returned errors with contextual messages before returning
(e.g., include the repository type and operation like
"DocumentSourceRepository.Create" and relevant identifiers such as source.ID)
using error wrapping (fmt.Errorf("DocumentSourceRepository.Create: %w", err) or
errors.Wrap) so callers receive meaningful, traceable errors—apply the same
pattern to the other methods in this file mentioned in the review.
- Around line 65-73: In FindAll, after the rows iteration loop that scans into
source and appends to sources, check rows.Err() and return any non-nil error
instead of returning a partial result; specifically, after the for rows.Next() {
... } block call if err := rows.Err(); err != nil { return nil, err } so the
method (FindAll in documentsource-repository.go) returns an error when the SQL
driver reports post-iteration errors instead of silently returning a partial
sources slice.
In
`@backend/go/internal/infrastructure/repository/postgres/documenttype-repository.go`:
- Around line 22-32: The repository methods (e.g.,
DocumentTypeRepository.Create) currently return raw DB errors from
r.pool.Exec/Query/QueryRow; update each method to wrap returned errors with
contextual messages before returning (for example: "Create document type: %w",
"Get document type by id: %w", "Update document type: %w", "Delete document
type: %w", etc.) so callers receive meaningful operation context—locate each
occurrence of r.pool.Exec, r.pool.Query, r.pool.QueryRow and replace direct
returns of err with wrapped errors using fmt.Errorf or errors.Wrapf with the
operation name and relevant identifiers (dt.ID, id, name) included.
- Around line 65-74: In DocumentTypeRepository.FindAll, after iterating rows
with rows.Next(), check rows.Err() and return a wrapped error if non-nil (e.g.,
fmt.Errorf("DocumentTypeRepository.FindAll: rows iteration: %w", err)) so
late/stream errors aren't swallowed; modify the function that scans into dt and
appends to dts to perform this rows.Err() check just before returning dts.
- Around line 80-85: The update/delete currently returns the Exec error
directly, which hides the case where no rows matched; in the
DocumentTypeRepository replace the current r.pool.Exec(...) return with
capturing the result (e.g., tag, err := r.pool.Exec(...)), check err first, then
call tag.RowsAffected(); if RowsAffected() == 0 return the repository/service
not-found error (the package's ErrNotFound or ErrDocumentTypeNotFound) so the
API can translate it to 404, otherwise return nil; apply the same pattern for
both the UPDATE and DELETE usages of r.pool.Exec.
In
`@backend/go/internal/infrastructure/repository/postgres/subject-repository.go`:
- Around line 112-129: Both Update and Delete currently ignore the Exec result
and always return nil even if no rows were affected; change both methods (Update
and Delete) to capture the Exec command tag (e.g., cmdTag, err :=
r.pool.Exec(...)), check cmdTag.RowsAffected() and if it equals 0 return a
not-found error (use the domain error type if one exists, e.g.,
subject.ErrNotFound or a sentinel repository not-found error) otherwise return
err; ensure you still return any Exec error when err != nil and only return the
not-found error when Exec succeeds but RowsAffected() == 0.
In
`@backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go`:
- Around line 53-69: GetNextPendingJob currently does a non-locked SELECT and
Update does not enforce previous status, allowing multiple workers to claim the
same job; fix by making the claim atomic either inside GetNextPendingJob or a
new ClaimNextPendingJob method: open a transaction, run a SELECT ... FOR UPDATE
SKIP LOCKED (or perform an UPDATE ... WHERE status='pending' ORDER BY created_at
ASC LIMIT 1 SET status='claimed' RETURNING ...) to atomically mark the job as
claimed and return it, and also change UploadJobRepository.Update to include a
precondition on status (e.g., WHERE id=$1 AND status=$2) so updates only succeed
if the job is in the expected state.
In `@backend/go/internal/infrastructure/repository/postgres/user-repository.go`:
- Around line 91-108: The Update and Delete methods currently ignore the Exec
command tag so operations on nonexistent IDs report success; change both
functions (Update and Delete) to capture the Exec result (cmdTag, err :=
r.pool.Exec(...)), check cmdTag.RowsAffected(), and if it equals 0 return a
not-found error instead of nil; create or use a sentinel not-found error (e.g.,
repository.ErrNotFound or user.ErrNotFound) and return that when RowsAffected()
== 0, otherwise return any Exec error or nil as appropriate.
In `@backend/go/internal/infrastructure/segmentation/segmentation.go`:
- Around line 297-335: The current buildChaptersFromResponse only clamps
per-chapter indices but does not normalize across the batch; after you construct
the result slice in buildChaptersFromResponse, sort the chapters by their
StartChunkIndex, then walk them and normalize ranges to be sorted,
non-overlapping, and gap-free across [0..maxIndex]: for each chapter (use
ChapterOrder, StartChunkIndex, EndChunkIndex fields), set its start =
max(previousEnd+1, clampedStart), set its end = max(start, clampedEnd), and if
you find a final gap or leftover chunks after the last chapter assign them to
the last chapter’s EndChunkIndex (or create/adjust boundaries so coverage is
complete). Ensure ChapterOrder values remain sequential (i+1) and preserve other
fields like ConfidenceScore and Title; this guarantees UpdateChapterIDRange will
not see overlapping or uncovered chunk ranges.
In `@backend/go/internal/infrastructure/worker/background_worker.go`:
- Around line 273-277: The code is auto-approving processed uploads by setting
doc.Status = "completed" and doc.ApprovedAt = &doc.CreatedAt; instead, set the
document into the pending-review state used by DocumentService and admin
handlers. Change the assignment so doc.Status is set to "pending" (or whatever
the existing pending constant/state is) and remove any assignment to
doc.ApprovedAt so it remains nil; keep updating doc.TotalChunks and
doc.UpdatedAt as-is so the processing metadata is preserved.
- Around line 84-112: processNextJob currently calls jobRepo.GetNextPendingJob
then later updates status, which allows a double-pick race; change the repo API
to atomically claim a job and return it (e.g., add
jobRepo.ClaimNextPendingJob(ctx) which performs an UPDATE ... SET
status='processing', claimed_at=..., RETURNING * inside the DB/transaction) and
use that in processNextJob instead of GetNextPendingJob; update processNextJob
to handle a nil return, remove the separate status flip (or the early
updateJobProgress call) since the claim already marks the job processing, and
keep existing failJob/updateJobProgress usage for later lifecycle events.
- Around line 220-228: Validate that embeddings returned by
w.embedder.EmbedBatch match the inputs and expected dimension before
assigning/persisting: check len(embeddings) == len(texts) and each embedding's
length == 768, and if either check fails call w.failJob(ctx, job, ...) with a
clear error detailing the mismatch; only after these assertions proceed to
populate batch[idx].Embedding and call the repository CreateBatch (reference
variables/functions: EmbedBatch, embeddings, texts, batch, CreateBatch,
w.failJob, w.embedder). Ensure the failure message includes actual
counts/dimensions to aid debugging.
In `@backend/go/internal/interface/handler/document-handler.go`:
- Around line 394-455: The Edit handler currently trusts the JSON body input.ID
when calling UpdateDocument, allowing clients to edit any document; retrieve the
route slug with c.Param("slug") (or resolve the document ID from that slug),
parse/resolve it to a uuid and then either replace docID with this slug-derived
ID before calling h.service.UpdateDocument or validate that input.ID matches the
slug-derived ID and return a 400/403 if they differ. Update the Edit function to
parse c.Param("slug") (or call your existing slug->ID resolver), use that ID
when invoking UpdateDocument (or explicitly compare it to input.ID) and return
an error response if parsing/resolution fails or the IDs do not match. Ensure
references: Edit, input.ID, c.Param("slug"), and h.service.UpdateDocument are
updated accordingly.
- Around line 327-333: When EnqueueUploadJob returns an error, the handler must
roll back the partially-created resources: delete the document row (saved.ID)
and remove the uploaded S3 object (s3Key) before returning the 500. Update the
error branch after h.service.EnqueueUploadJob(c.Request.Context(), userID,
saved.ID, file.Filename, s3Key, file.Size) to perform best-effort cleanup using
the service/repo and S3 APIs (e.g., call a delete-document method with saved.ID
and an S3 delete with s3Key using c.Request.Context()), log any cleanup errors,
then return the original error response; keep the rollback best-effort so
cleanup failures do not mask the enqueue error.
- Around line 369-372: The handler currently maps all errors from
h.service.GetDocumentDetailsBySlug to 403; change the error handling to return
403 only when the error is an access-denied/authorization error (e.g., use
errors.Is(err, service.ErrAccessDenied) or check for the AccessDeniedError type
returned by GetDocumentDetailsBySlug) and return 500 (Internal Server Error) for
all other errors; update the response calls (currently using c.JSON with
http.StatusForbidden) to use http.StatusInternalServerError for non-auth errors
and include err.Error() in the JSON body to aid debugging.
- Around line 595-607: The handler currently ignores errors from
h.service.GetSubjects, GetDocumentTypes, GetLanguages, GetDocumentSources, and
GetAcademicTerms and always returns 200; change it to check each returned error
and if any call returns a non-nil error respond with an error HTTP status (e.g.,
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error":
err.Error()})) returning the first error encountered, otherwise return the
aggregated dropdown data; ensure you reference the service call names above when
adding the error checks and include meaningful error messages in the JSON
response instead of silently dropping failures.
In `@backend/go/internal/interface/router/router.go`:
- Around line 25-29: The CORS middleware registered via r.Use (the anonymous
func receiving *gin.Context) currently sets Access-Control-Allow-Origin="*"
while also allowing credentials; update it to read allowed origins from an env
var (e.g., FRONTEND_ORIGINS or FRONTEND_ORIGIN), parse a comma-separated list,
inspect the incoming request Origin (c.Request.Header.Get("Origin")) and if it
matches one of the approved origins (for local dev include http://localhost:3000
or the configured Next.js origin) echo that exact Origin with
c.Writer.Header().Set("Access-Control-Allow-Origin", origin) and keep
Access-Control-Allow-Credentials: "true"; if no match, do not set
Allow-Credentials and do not echo "*". Also ensure preflight OPTIONS requests
are handled (set Access-Control-Allow-Methods and Headers and return 200 for
OPTIONS) and remove any hardcoded "*" values so all config comes from
environment variables.
In `@backend/go/migrations/001_initial.sql`:
- Around line 8-37: Remove the multi-role/admin model from the migration: delete
the roles table creation and seed INSERT statements (references to roles and the
INSERT values), and remove the role_id column and any references to roles from
the users table definition (the "role_id SMALLINT NOT NULL DEFAULT 3 REFERENCES
roles(id)" fragment). Keep only basic user account fields (UUID id, email, name,
timestamps, emailVerified, image, username/displayUsername, is_active,
is_blocked) and ensure no role-related schema or seeding remains so the DB
reflects simple user accounts only.
- Around line 179-192: The migration currently creates document_chunks.embedding
as vector(3072); change the schema to declare embedding as vector(768) to match
Gemini Embedding 2. Update the CREATE TABLE statement for document_chunks to use
vector(768) and, if this migration may run against an existing DB that already
has vector(3072), add a follow-up migration that alters
document_chunks.embedding to vector(768) (or recreates the column safely) so
existing data/operations are not broken; ensure the migration runs before any
code writes embeddings. Reference: document_chunks table and the embedding
column.
In `@backend/go/pkg/config/env.go`:
- Around line 7-10: Add a new BETTER_AUTH_SECRET field to the Config struct in
env.go alongside JWT_SECRET and expose it from env loading; then update the auth
middleware that currently reads JWT_SECRET (e.g., the token validation function
or middleware that calls JWT parsing/validation) to also read and use
Config.BETTER_AUTH_SECRET when verifying tokens issued by Better Auth (or prefer
BETTER_AUTH_SECRET for cross-service tokens), ensuring the validation uses the
new config value for signature/key verification.
- Around line 37-49: The Load function currently supplies insecure hardcoded
defaults for security-critical vars; change Load to return (*Config, error) and
remove defaults for DATABASE_URL and JWT_SECRET (and other secrets like
AWS_SECRET_ACCESS_KEY/AWS_ACCESS_KEY_ID/AWS_S3_BUCKET if appropriate), implement
a helper (e.g., getEnvRequired) that returns an error when a required env var is
missing or empty, call that for required keys in Load, use parseInt on validated
strings for MAX_FILE_SIZE, and propagate explicit errors instead of falling back
to hardcoded values so the process fails fast on missing configuration.
---
Minor comments:
In `@AGENTS.md`:
- Around line 108-117: The fenced code block showing the architecture diagram
lacks a language tag which triggers markdownlint; update the triple-backtick
fence before the diagram to include the language token "text" (i.e., change ```
to ```text) so the block becomes a fenced "text" code block; target the fenced
architecture block that contains the lines starting with "Browser ──► Hono
Backend..." and "Browser ──► Next.js..." and adjust the opening fence
accordingly.
In
`@backend/go/internal/infrastructure/repository/postgres/chapter-repository.go`:
- Around line 73-84: FindByDocumentID currently iterates over rows but doesn't
check for terminal iteration errors; after the for rows.Next() loop (in the
FindByDocumentID implementation) call rows.Err() and if it returns a non-nil
error return it (or wrap it) instead of returning chapters, ensuring any
driver/stream errors are surfaced; keep the existing rows closing logic intact.
In
`@backend/go/internal/infrastructure/repository/postgres/documentfile-repository.go`:
- Around line 67-76: The rows iteration in FindByDocumentID does not check for
iteration errors; after the for rows.Next() loop and before returning files, add
an if err := rows.Err(); err != nil { return nil, err } check to surface any
errors encountered during iteration (refer to the rows variable and the
FindByDocumentID function where rows.Next() and rows.Scan(...) are used).
In
`@backend/go/internal/infrastructure/repository/postgres/documentreport-repository.go`:
- Around line 76-85: The iteration over SQL rows in FindPending and
FindByDocumentID currently returns the accumulated reports without checking
rows.Err(), which can silently ignore driver iteration errors; after the for
rows.Next() { ... } loop in both FindPending and FindByDocumentID, add a check
like if err := rows.Err(); err != nil { return nil, err } so any iteration error
from rows is propagated (ensure you perform this check before returning reports
and after the loop that populates reports).
In
`@backend/go/internal/infrastructure/repository/postgres/language-repository.go`:
- Around line 65-74: The FindAll implementation in LanguageRepository iterates
rows with for rows.Next() but never checks rows.Err(), so scanning/iteration
errors can be lost; after the loop in LanguageRepository.FindAll, call
rows.Err() and if non-nil return nil and that error (or wrap it) instead of
returning langs,nil, ensuring iteration errors from rows are propagated back to
the caller.
In
`@backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go`:
- Around line 88-97: The iteration over SQL rows in FindActiveByOwner currently
only checks errors from rows.Scan() but not the iterator itself; after the for
rows.Next() loop (and before returning jobs), call rows.Err() and if non-nil
return that error (e.g., return nil, rows.Err()) so any deferred iteration
errors are surfaced; update the function containing rows.Next()/rows.Scan() to
perform this rows.Err() check.
---
Nitpick comments:
In `@backend/go/pkg/config/env.go`:
- Around line 27-35: The project currently has an unused parseInt function and
Config.MAX_FILE_SIZE; either remove the unused config and parseInt, or wire
MAX_FILE_SIZE into the file-upload flow: replace parseInt with strconv.ParseInt
when loading MAX_FILE_SIZE in the Config.Load() (propagate and return a clear
error like "invalid MAX_FILE_SIZE" on parse failure), store the parsed value on
Config.MAX_FILE_SIZE, and use that Config.MAX_FILE_SIZE instead of the hardcoded
MaxFileSize constant in internal/infrastructure/filestorage/local.go (adjust
local.go to accept the config value or pass it into the uploader
initialization).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
| // split token for Better Auth | ||
| if parts := strings.SplitN(tokenString, ".", 2); len(parts) == 2 { | ||
| tokenString = parts[0] | ||
| } | ||
|
|
||
| // Query Better Auth session from database | ||
| // Query Better Auth session from database with roles/status | ||
| var userIDStr, email string | ||
| var roleID int16 | ||
| var isActive, isBlocked bool | ||
| err := db.QueryRow(context.Background(), ` | ||
| SELECT s."userId", u.email | ||
| SELECT s."userId", u.email, u.role_id, u.is_active, u.is_blocked | ||
| FROM session s | ||
| JOIN users u ON s."userId" = u.id | ||
| WHERE s.token = $1 AND s."expiresAt" > NOW() | ||
| `, tokenString).Scan(&userIDStr, &email) | ||
| `, tokenString).Scan(&userIDStr, &email, &roleID, &isActive, &isBlocked) |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
For Better Auth, when a token is sent in the Authorization header, should the backend ever split the token on "." before validation, or must it validate the full token signature with BETTER_AUTH_SECRET and use the sub claim as the user ID?
💡 Result:
When a token is sent in the Authorization header within the Better Auth ecosystem, you should not manually split the token on "." to extract claims [1][2]. Instead, you must validate the token's signature using the appropriate cryptographic keys to ensure its authenticity and integrity [1][2]. For JWT-based authentication in Better Auth, the standard approach is to use the JWKS (JSON Web Key Set) endpoint provided by your auth server (typically at /api/auth/jwks) to verify the token [1][2]. Libraries such as jose are recommended for this process [2]. The verification process involves checking the signature against the public keys fetched from the JWKS endpoint, as well as validating claims such as the issuer (iss) and audience (aud) to ensure they match your expected configuration [1][2]. Manual splitting of the token string bypasses these critical security checks, which is insecure and leaves your application vulnerable to unauthorized access [1][2]. Once the token is successfully validated, you can then securely access the claims within the payload, such as the sub claim, to identify the user [1][2]. If you are using opaque tokens, you should use the designated introspection endpoint rather than manual parsing [3].
Citations:
- 1: https://better-auth.com/docs/plugins/jwt
- 2: https://mintlify.wiki/better-auth/better-auth/plugins/jwt
- 3: OAuth 2.1 Provider: How to verify on api server? better-auth/better-auth#8947
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate and display the auth middleware with line numbers
FILE="backend/go/internal/interface/middleware/auth.go"
if [ -f "$FILE" ]; then
echo "=== $FILE ==="
nl -ba "$FILE" | sed -n '1,220p'
else
echo "Missing file: $FILE"
exit 1
fi
# Check whether BETTER_AUTH_SECRET is used anywhere
echo "=== Search: BETTER_AUTH_SECRET usage ==="
rg -n "BETTER_AUTH_SECRET" backend/go || true
# Check for JWT verification libs/usage and 'sub' claim validation
echo "=== Search: JWT / 'sub' validation patterns ==="
rg -n "jwt|JWKS|jose|sub['\"]?|claims\[['\"]sub['\"]?\]|Parse.*token|Verify.*token" backend/go || true
# Check for any other auth middleware/token handling
echo "=== Search: Authorization header handling in backend/go ==="
rg -n "Authorization|auth header|Bearer " backend/go/internal/interface/middleware backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 189
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="backend/go/internal/interface/middleware/auth.go"
if [ -f "$FILE" ]; then
echo "=== $FILE ==="
cat -n "$FILE" | sed -n '1,240p'
else
echo "Missing file: $FILE"
exit 1
fi
echo "=== Search: BETTER_AUTH_SECRET usage ==="
rg -n "BETTER_AUTH_SECRET" backend/go || true
echo "=== Search: JWT / 'sub' validation patterns ==="
rg -n "jwt|JWKS|jose|sub['\"]?|claims\[['\"]sub['\"]?\]|Parse.*token|Verify.*token|VerifyToken|ParseJWT" backend/go || true
echo "=== Search: Authorization header handling in backend/go ==="
rg -n "Authorization|auth header|Bearer " backend/go/internal/interface/middleware backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 34286
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search entire repo for BETTER_AUTH_SECRET ==="
rg -n "BETTER_AUTH_SECRET" . || true
echo "=== Search for Better Auth session/JWT related identifiers ==="
rg -n "better-auth|jwks|JWKS|jose|jwt|ParseJWT|VerifyToken|claims\\s*\\[\\s*['\"]sub['\"]\\s*\\]|\"sub\"\\s*:" backend/go || true
echo "=== Search for env var usage patterns in backend/go auth/server ==="
rg -n "os\\.Getenv\\(|env\\." backend/go/internal/interface backend/go/cmd backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 972
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search entire repo for BETTER_AUTH_SECRET ==="
rg -n "BETTER_AUTH_SECRET" . || true
echo "=== Search for Better Auth session/JWT related identifiers ==="
rg -n "better-auth|jwks|JWKS|jose|jwt|ParseJWT|VerifyToken|claims\\s*\\[\\s*['\"]sub['\"]\\s*\\]|\"sub\"\\s*:" backend/go || true
echo "=== Search for env var usage patterns in backend/go auth/server ==="
rg -n "os\\.Getenv\\(|env\\." backend/go/internal/interface backend/go/cmd backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 972
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search migrations for session/token columns ==="
rg -n "CREATE TABLE IF NOT EXISTS session|FROM session|JOIN session|token\\b|expiresAt\\b|userId\\b" backend/go/migrations backend/go/internal || true
echo "=== Search Go code for JWT libraries/usages (more targeted) ==="
rg -n "jose|jwt-go|golang-jwt|ParseWithClaims|SignedString|Verify|JWKS|jwks\\.json|kid" backend/go || true
echo "=== Search for QueryRow selecting from session in Go ==="
rg -n "QueryRow\\(context\\.[^,]*,\\s*`[\\s\\S]*FROM session|FROM session" backend/go/internal/interface/middleware backend/go/internal || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 2650
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Show session table definition (migrations/001_initial.sql) ==="
# Print around the session table creation
python3 - <<'PY'
import itertools, re, sys
path="backend/go/migrations/001_initial.sql"
start=None
end=None
with open(path,'r',encoding='utf-8') as f:
lines=f.readlines()
for i,l in enumerate(lines):
if 'CREATE TABLE IF NOT EXISTS session' in l:
start=i
break
if start is None:
print("session table not found")
sys.exit(0)
# print next 60 lines
for j in range(start, min(start+80, len(lines))):
print(f"{j+1:5d}\t{lines[j].rstrip()}")
PY
echo "=== Re-search for JWT verification/parsing in backend/go ==="
rg -n "jose|jwks|JWKS|jwt-go|golang-jwt|ParseWithClaims|SignedString|Verify\\w*\\(|Verify\\w*Token|Parse\\w*JWT|claims\\s*\\[\\s*['\"]sub['\"]\\s*\\]|\\\"sub\\\"\\s*:" backend/go || true
echo "=== Re-search for BETTER_AUTH_SECRET usage in backend/go ==="
rg -n "BETTER_AUTH_SECRET" backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 3714
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Show session table definition (migrations/001_initial.sql) ==="
python3 - <<'PY'
path="backend/go/migrations/001_initial.sql"
with open(path,'r',encoding='utf-8') as f:
lines=f.readlines()
start=None
for i,l in enumerate(lines):
if 'CREATE TABLE IF NOT EXISTS session' in l:
start=i
break
if start is None:
print("session table not found")
raise SystemExit(0)
for j in range(start, min(start+90, len(lines))):
print(f"{j+1:5d}\t{lines[j].rstrip()}")
PY
echo "=== Re-search for JWT verification/parsing in backend/go ==="
rg -n "jose|jwks|JWKS|jwt-go|golang-jwt|ParseWithClaims|SignedString|Verify\\w*\\(|Verify\\w*Token|Parse\\w*JWT|claims\\s*\\[\\s*['\"]sub['\"]\\s*\\]|\\\"sub\\\"\\s*:" backend/go || true
echo "=== Re-search for BETTER_AUTH_SECRET usage in backend/go ==="
rg -n "BETTER_AUTH_SECRET" backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 4280
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Search backend/go for JWT/JWKS/JWT verification related strings ==="
rg -ni --hidden --glob '!**/go.sum' \
"jwt|jose|jwks|ParseWithClaims|SignedString|VerifyToken|VerifyJWT|verification\\s*token|Parse\\w*JWT|claims|\\\"sub\\\"|\\bsub\\b" \
backend/go || true
echo "=== Search backend/go for Better Auth related packages/usages ==="
rg -ni "better-auth|better auth" backend/go || true
echo "=== Search backend/go for BETTER_AUTH_SECRET usage ==="
rg -n "BETTER_AUTH_SECRET" backend/go || trueRepository: UGing265/SWD392_Chatbot_RAG
Length of output: 1196
Stop truncating Better Auth tokens and enforce JWT verification (BETTER_AUTH_SECRET) + sub validation.
backend/go/internal/interface/middleware/auth.go splits the credential on the first . (strings.SplitN(tokenString, ".", 2) → keep prefix) and then authenticates only by DB lookup of session.token (WHERE s.token = $1 ...). This performs no JWT signature verification and no JWT sub claim validation, and Go backend code does not reference BETTER_AUTH_SECRET (so it can’t meet the Better Auth JWT contract). Also, backend/go/migrations/001_initial.sql defines session.token as TEXT, so the session-row match is not equivalent to cryptographic JWT verification.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/go/internal/interface/middleware/auth.go` around lines 39 - 53, The
code currently truncates the credential via strings.SplitN(tokenString, ".", 2)
and only matches session.token in the DB without any JWT signature or sub claim
checks; instead, stop truncating the tokenString, parse and verify the JWT
signature using the BETTER_AUTH_SECRET (e.g., via a JWT library in the auth
middleware where tokenString is handled), validate the token is not expired and
extract the "sub" claim, then QueryRow (db.QueryRow) to load the session/user
and ensure the session belongs to the same subject (compare JWT sub to the
session's userId/returned userIdStr) and/or that the session token corresponds
to a signed JWT, returning unauthorized if signature/sub validation fails.
Ensure error handling logs/returns unauthorized on missing/invalid
BETTER_AUTH_SECRET or invalid JWT signature/claims.
|
@claude review |
|
@claude review |
f971f64 to
e81900f
Compare
CI Feedback 🧐A test triggered by this PR failed. Here is an AI-generated analysis of the failure:
|
Summary by CodeRabbit