Skip to content

feat: add document services, basic CRUD for entities - #1

Merged
UGing265 merged 12 commits into
mainfrom
feat/port-to-golang
Jun 3, 2026
Merged

feat: add document services, basic CRUD for entities#1
UGing265 merged 12 commits into
mainfrom
feat/port-to-golang

Conversation

@UGing265

@UGing265 UGing265 commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features
    • Introduced a standalone authentication service with Swagger UI, health check, and CORS (default at http://localhost:5000).
    • Launched a Go-based RAG backend (default at http://localhost:8080) with public, lecturer, and admin endpoints for document management, uploads, reporting, and moderation.
    • Added S3-backed file storage and background processing for document parsing, embeddings, and chaptering.
  • Documentation
    • Overhauled architecture docs and added an API Reference and Startup Guide.
  • Chores
    • New script to run both backends concurrently.
  • Bug Fixes
    • Improved login/register error messaging.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Port backend to Go with complete document CRUD, AI processing, and role-based access control

✨ Enhancement

Grey Divider

Walkthroughs

Description
• **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
Diagram
flowchart 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

Loading

Grey Divider

File Changes

1. backend/go/docs/docs.go 📝 Documentation +2574/-0

Auto-generated Swagger API documentation specification

• Auto-generated Swagger/OpenAPI documentation file containing complete API specification
• Defines 30+ REST endpoints covering admin operations, document management, and metadata CRUD
• Includes request/response schemas for DTOs and input models
• Specifies security definitions using BearerAuth token authentication

backend/go/docs/docs.go


2. backend/go/internal/interface/handler/admin-handler.go ✨ Enhancement +712/-0

Admin HTTP handlers for metadata and document management

• Implements AdminHandler struct with 20+ HTTP handler methods for admin operations
• Provides CRUD endpoints for metadata entities: subjects, document types, languages, academic
 terms, document sources
• Implements document approval/rejection and user blocking/unblocking functionality
• Includes report resolution and document listing with filtering capabilities

backend/go/internal/interface/handler/admin-handler.go


3. backend/go/internal/infrastructure/repository/postgres/document-repository.go ✨ Enhancement +486/-0

PostgreSQL repository for document persistence layer

• Implements DocumentRepository with database operations for document entities
• Provides methods for CRUD operations and complex queries: FindByID, FindBySlug,
 FindAllPublic, FindAllOwned, FindAllAdmin
• Includes filtering, sorting, and pagination logic for document listings
• Implements counting methods for documents, files, and chunks by owner or document

backend/go/internal/infrastructure/repository/postgres/document-repository.go


View more (122)
4. backend/go/internal/infrastructure/repository/postgres/documentreport-repository.go ✨ Enhancement +138/-0

PostgreSQL repository for document report management

• Implements DocumentReportRepository for document violation report persistence
• Provides methods to create, retrieve, and update document reports
• Includes queries for pending reports and reports by document ID
• Implements deletion operations for individual reports and by document

backend/go/internal/infrastructure/repository/postgres/documentreport-repository.go


5. backend/go/internal/application/document_service.go ✨ Enhancement +1659/-0

Core document service with CRUD and metadata management

• Comprehensive document service implementation with 1600+ lines covering core CRUD operations,
 metadata management, and reporting functionality
• Defines 20+ DTOs for API responses including DocumentDetailsDto, MyDocumentsDto,
 DashboardSummaryDto, and metadata DTOs
• Implements document lifecycle: creation with MD5 deduplication, S3 upload, slug generation,
 visibility controls, and deletion with cleanup
• Provides metadata CRUD for subjects, document types, languages, sources, and academic terms with
 validation and duplicate checking
• Includes document reporting system with pending report retrieval and resolution actions (delete or
 resolve)

backend/go/internal/application/document_service.go


6. backend/go/internal/interface/handler/document-handler.go ✨ Enhancement +608/-0

HTTP handlers for document API endpoints

• HTTP handler layer with 8 main endpoints for document operations (list, upload, details, edit,
 delete, report, dashboard)
• Implements multipart file upload with validation for allowed extensions and MIME types, with
 detailed logging
• Provides query parameter parsing for filtering, pagination, and sorting across multiple document
 listing endpoints
• Includes Swagger documentation annotations for all endpoints with proper HTTP status codes and
 response schemas

backend/go/internal/interface/handler/document-handler.go


7. backend/go/internal/infrastructure/segmentation/segmentation.go ✨ Enhancement +410/-0

Gemini-based AI chapter segmentation service

• AI-powered chapter segmentation service using Google Gemini API with batch processing and fallback
 mechanisms
• Implements multi-key rotation for API calls with retry logic across multiple API keys
• Processes document chunks in batches of 40 with context awareness to maintain chapter continuity
 across batches
• Includes JSON extraction and validation with auto-closing of malformed brackets, plus fallback
 single-chapter generation

backend/go/internal/infrastructure/segmentation/segmentation.go


8. backend/go/internal/infrastructure/worker/background_worker.go ✨ Enhancement +354/-0

Background worker for document processing pipeline

• Background job processor for document upload pipeline with 7-step workflow (file download,
 parsing, chunking, embedding, saving, segmentation, completion)
• Implements batch embedding with Gemini API (50 chunks per batch) with rate limiting and progress
 tracking
• Handles file extraction, text sanitization, SHA256 checksum computation, and metadata JSON
 serialization
• Provides comprehensive error handling with job failure tracking and automatic document status
 updates

backend/go/internal/infrastructure/worker/background_worker.go


9. backend/go/internal/interface/router/router.go ✨ Enhancement +147/-0

Router configuration with role-based access control

• Complete router setup with CORS middleware, S3 storage initialization, and repository/service
 dependency injection
• Defines protected routes with role-based access control (admin role 1, lecturer role 2, student
 role 3)
• Organizes endpoints into public health check, protected document operations, and admin management
 sections
• Includes Swagger documentation endpoint and admin CRUD routes for subjects, document types,
 languages, sources, and academic terms

backend/go/internal/interface/router/router.go


10. backend/go/internal/infrastructure/repository/postgres/subject-repository.go ✨ Enhancement +130/-0

Subject repository with PostgreSQL persistence

• PostgreSQL repository implementation for Subject entity with standard CRUD operations
• Implements FindAllByOwner to retrieve subjects associated with a specific user's documents
• Includes LEFT JOIN with academic_terms table to fetch related term names in queries
• Uses context timeouts (5-10 seconds) and proper error handling for database operations

backend/go/internal/infrastructure/repository/postgres/subject-repository.go


11. backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go ✨ Enhancement +117/-0

Upload job repository with CRUD and job queue operations

• New PostgreSQL repository implementation for upload job CRUD operations
• Implements methods for creating, finding, updating, and deleting upload jobs
• Includes GetNextPendingJob() for background worker job processing
• Supports finding active jobs by owner with status filtering

backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go


12. backend/go/internal/infrastructure/repository/postgres/user-repository.go ✨ Enhancement +109/-0

User repository with role-based data access layer

• New PostgreSQL repository for user data access with role information
• Implements FindByID(), FindByEmail(), FindAll(), Update(), and Delete() methods
• Joins with roles table to retrieve role names and user status fields
• Handles both legacy and Better Auth timestamp fields

backend/go/internal/infrastructure/repository/postgres/user-repository.go


13. backend/go/internal/infrastructure/repository/postgres/chunk-repository.go ✨ Enhancement +109/-0

Chunk repository with vector embedding and batch operations

• New repository for document chunk management with vector embeddings
• Implements batch insert operations for efficient chunk creation
• Supports finding chunks by document with embedding retrieval
• Includes UpdateChapterIDRange() for bulk chapter assignment to chunks

backend/go/internal/infrastructure/repository/postgres/chunk-repository.go


14. backend/go/internal/infrastructure/repository/postgres/documentfile-repository.go ✨ Enhancement +92/-0

Document file repository with S3 integration support

• New repository for document file metadata management
• Implements CRUD operations for document files with S3 storage references
• Supports finding files by document ID and deletion operations
• Stores file metadata including MIME type, checksums, and extraction status

backend/go/internal/infrastructure/repository/postgres/documentfile-repository.go


15. backend/go/internal/infrastructure/repository/postgres/chapter-repository.go ✨ Enhancement +92/-0

Chapter repository with batch operations and AI metadata

• New repository for document chapter (table of contents) management
• Implements batch creation for efficient chapter insertion
• Supports finding chapters by document with ordering
• Stores AI-generated chapter metadata including confidence scores

backend/go/internal/infrastructure/repository/postgres/chapter-repository.go


16. backend/go/internal/interface/middleware/auth.go ✨ Enhancement +47/-6

Authentication middleware with role-based authorization

• Enhanced authentication middleware with user status validation
• Added checks for blocked and inactive user accounts
• Extracts and stores role_id in context for authorization
• New RequireRoles() middleware for role-based access control

backend/go/internal/interface/middleware/auth.go


17. backend/go/internal/infrastructure/repository/postgres/academicterm-repository.go ✨ Enhancement +94/-0

Academic term repository with CRUD operations

• New repository for academic term (semester) management
• Implements full CRUD operations with ordering support
• Provides FindAll() sorted by term order
• Stores term metadata for document classification

backend/go/internal/infrastructure/repository/postgres/academicterm-repository.go


18. backend/go/internal/infrastructure/repository/postgres/documenttype-repository.go ✨ Enhancement +94/-0

Document type repository for document classification

• New repository for document type classification
• Implements CRUD operations for document type metadata
• Supports finding all types sorted by name
• Stores type descriptions for document categorization

backend/go/internal/infrastructure/repository/postgres/documenttype-repository.go


19. backend/go/internal/infrastructure/repository/postgres/documentsource-repository.go ✨ Enhancement +94/-0

Document source repository for source tracking

• New repository for document source management
• Implements CRUD operations for document sources
• Provides FindAll() sorted by name
• Tracks origin/source of uploaded documents

backend/go/internal/infrastructure/repository/postgres/documentsource-repository.go


20. backend/go/internal/infrastructure/repository/postgres/language-repository.go ✨ Enhancement +94/-0

Language repository for document language support

• New repository for language metadata management
• Implements CRUD operations with language code and name
• Provides FindAll() sorted by name
• Supports document language classification

backend/go/internal/infrastructure/repository/postgres/language-repository.go


21. backend/go/cmd/server/main.go ✨ Enhancement +81/-0

Main server initialization with worker and router setup

• New main server entry point for Go backend
• Initializes database connection and AWS S3 storage
• Sets up all repositories and services for background worker
• Starts background upload worker and Gin router on port 8080

backend/go/cmd/server/main.go


22. backend/go/internal/infrastructure/embedding/gemini-embedding.go ⚙️ Configuration changes +8/-8

Embedding configuration update to 3072 dimensions

• Updated embedding dimensionality from 768 to 3072 for Gemini API
• Reduced max retries from 20 to 3 for faster failure handling
• Updated all validation checks to expect 3072-dimensional vectors
• Applied changes to both single and batch embedding methods

backend/go/internal/infrastructure/embedding/gemini-embedding.go


23. backend/go/internal/infrastructure/fileparser/pdf-parser.go ✨ Enhancement +16/-1

PDF parser with detailed extraction logging

• Added comprehensive logging throughout PDF extraction process
• Logs file opening, page count, extraction progress, and errors
• Improved error handling with detailed error messages
• Tracks empty pages and extraction failures per page

backend/go/internal/infrastructure/fileparser/pdf-parser.go


24. backend/go/internal/domain/document/entity.go ✨ Enhancement +49/-0

Document domain entity with full metadata support

• New document domain entity with comprehensive fields
• Includes document metadata, status, visibility, and statistics
• Supports relationships with subjects, types, languages, and terms
• Defines document status constants (pending, processing, completed, rejected)

backend/go/internal/domain/document/entity.go


25. backend/go/internal/infrastructure/filestorage/s3.go ✨ Enhancement +67/-0

S3 file storage implementation for cloud storage

• New S3 file storage implementation using AWS SDK v2
• Implements Save(), OpenRead(), and Delete() operations
• Handles S3 bucket configuration and error handling
• Provides file upload and retrieval functionality

backend/go/internal/infrastructure/filestorage/s3.go


26. backend/go/internal/infrastructure/fileparser/pptx-parser.go 🐞 Bug fix +26/-27

PPTX parser refactored for robust text extraction

• Refactored PPTX text extraction to use XML token streaming
• Removed rigid XML struct-based parsing in favor of flexible token loop
• Improved text extraction from nested `` elements
• Better handling of whitespace and empty text nodes

backend/go/internal/infrastructure/fileparser/pptx-parser.go


27. backend/go/internal/infrastructure/repository/postgres/auditlog-repository.go ✨ Enhancement +55/-0

Audit log repository for action tracking

• New repository for audit log management
• Implements Create() for logging user actions
• Provides FindAll() sorted by creation date descending
• Tracks user actions, target tables, and IP addresses

backend/go/internal/infrastructure/repository/postgres/auditlog-repository.go


28. backend/go/pkg/config/env.go ✨ Enhancement +50/-0

Environment configuration loader with defaults

• New configuration loader for environment variables
• Supports database, JWT, Gemini API, AWS S3, and upload settings
• Provides default values for all configuration options
• Includes custom integer parsing for file size limits

backend/go/pkg/config/env.go


29. backend/go/internal/domain/document/repository.go ✨ Enhancement +37/-0

Document repository interface with filtering support

• New document repository interface defining CRUD and query operations
• Includes filtering parameters for search, subject, term, type, language, and source
• Supports role-based document retrieval (public, owned, admin)
• Provides counting methods for statistics

backend/go/internal/domain/document/repository.go


30. backend/go/internal/domain/user/entity.go ✨ Enhancement +32/-0

User domain entity with role and status fields

• New user domain entity with authentication and role fields
• Includes Better Auth fields and custom user metadata
• Supports user status tracking (active, blocked)
• Includes role information as related field

backend/go/internal/domain/user/entity.go


31. backend/go/internal/domain/documentfile/entity.go ✨ Enhancement +24/-0

Document file domain entity with storage metadata

• New document file domain entity
• Stores file metadata including MIME type, size, and checksums
• Supports S3 storage references and extraction status
• Includes page count and extracted text fields

backend/go/internal/domain/documentfile/entity.go


32. backend/go/internal/domain/chapter/entity.go ✨ Enhancement +23/-0

Chapter domain entity with hierarchical support

• New chapter domain entity for document table of contents
• Supports hierarchical chapters with parent-child relationships
• Includes AI-generated metadata and confidence scores
• Tracks page and chunk ranges for chapters

backend/go/internal/domain/chapter/entity.go


33. backend/go/internal/domain/uploadjob/entity.go ✨ Enhancement +22/-0

Upload job domain entity for background processing

• New upload job domain entity for background processing
• Tracks job status, progress percentage, and error messages
• Stores file metadata and storage path information
• Includes notification flag for completion alerts

backend/go/internal/domain/uploadjob/entity.go


34. backend/go/internal/domain/chunk/entity.go ✨ Enhancement +21/-0

Chunk domain entity with vector embedding support

• New chunk domain entity for document content segments
• Stores text content with token count and hash
• Includes vector embedding for semantic search
• Supports metadata as JSON and chapter associations

backend/go/internal/domain/chunk/entity.go


35. backend/go/internal/domain/chunk/repository.go ✨ Enhancement +1/-7

Chunk repository interface simplified and updated

• Updated chunk repository interface removing search and course-related methods
• Kept core CRUD and batch operations
• Added UpdateChapterIDRange() for bulk chapter assignment
• Simplified interface to focus on document-based operations

backend/go/internal/domain/chunk/repository.go


36. backend/go/internal/domain/documentreport/entity.go ✨ Enhancement +21/-0

Document report entity for violation tracking

• New document report domain entity for violation reporting
• Tracks reporter, reason, and resolution status
• Includes related document and reporter information
• Supports report status tracking

backend/go/internal/domain/documentreport/entity.go


37. backend/go/internal/domain/auditlog/entity.go ✨ Enhancement +18/-0

Audit log entity for system action tracking

• New audit log domain entity for action tracking
• Records user actions, target tables, and affected IDs
• Includes IP address and description fields
• Tracks creation timestamp for audit trail

backend/go/internal/domain/auditlog/entity.go


38. backend/go/internal/domain/documentreport/repository.go ✨ Enhancement +17/-0

Document report repository interface

• New document report repository interface
• Supports CRUD operations and status queries
• Includes finding pending reports and reports by document
• Provides bulk deletion by document ID

backend/go/internal/domain/documentreport/repository.go


39. backend/go/internal/domain/subject/entity.go ✨ Enhancement +18/-0

Subject domain entity for course classification

• New subject domain entity for course classification
• Stores subject code, name, and academic term association
• Includes related academic term name field
• Supports document subject categorization

backend/go/internal/domain/subject/entity.go


40. backend/go/internal/domain/uploadjob/repository.go ✨ Enhancement +16/-0

Upload job repository interface

• New upload job repository interface
• Defines CRUD operations and job queue methods
• Includes GetNextPendingJob() for background worker
• Supports finding active jobs by owner

backend/go/internal/domain/uploadjob/repository.go


41. backend/go/internal/domain/subject/repository.go ✨ Enhancement +16/-0

Subject repository interface

• New subject repository interface
• Supports CRUD operations and finding by owner
• Provides FindAll() for listing all subjects
• Enables subject-based document filtering

backend/go/internal/domain/subject/repository.go


42. backend/go/internal/domain/documentfile/repository.go ✨ Enhancement +15/-0

Document file repository interface

• New document file repository interface
• Defines CRUD operations for file metadata
• Supports finding files by document ID
• Includes bulk deletion by document

backend/go/internal/domain/documentfile/repository.go


43. backend/go/internal/domain/user/repository.go ✨ Enhancement +15/-0

User repository interface

• New user repository interface
• Supports finding users by ID and email
• Provides FindAll() for user listing
• Includes update and delete operations

backend/go/internal/domain/user/repository.go


44. backend/go/internal/domain/documentsource/entity.go ✨ Enhancement +3/-4

Document source entity refactored from course

• Renamed package from course to documentsource
• Renamed struct from Course to DocumentSource
• Removed Textbook field from entity
• Simplified to store only name and creation timestamp

backend/go/internal/domain/documentsource/entity.go


45. backend/go/internal/domain/documenttype/repository.go ✨ Enhancement +15/-0

Document type repository interface

• New document type repository interface
• Supports CRUD operations for document types
• Provides FindAll() for listing all types
• Enables document type-based filtering

backend/go/internal/domain/documenttype/repository.go


46. backend/go/internal/domain/academicterm/repository.go ✨ Enhancement +15/-0

Academic term repository interface

• New academic term repository interface
• Supports CRUD operations for academic terms
• Provides FindAll() for listing all terms
• Enables term-based document filtering

backend/go/internal/domain/academicterm/repository.go


47. backend/go/internal/domain/documentsource/repository.go ✨ Enhancement +15/-0

Document source repository interface

• New document source repository interface
• Supports CRUD operations for document sources
• Provides FindAll() for listing all sources
• Enables source-based document filtering

backend/go/internal/domain/documentsource/repository.go


48. backend/go/internal/domain/chapter/repository.go ✨ Enhancement +14/-0

Chapter repository interface

• New chapter repository interface
• Supports CRUD and batch operations
• Provides finding chapters by document with ordering
• Includes bulk deletion by document ID

backend/go/internal/domain/chapter/repository.go


49. backend/go/internal/domain/language/repository.go ✨ Enhancement +15/-0

Language repository interface

• New language repository interface
• Supports CRUD operations for languages
• Provides FindAll() for listing all languages
• Enables language-based document filtering

backend/go/internal/domain/language/repository.go


50. backend/go/internal/domain/academicterm/entity.go ✨ Enhancement +14/-0

Academic term domain entity

• New academic term domain entity
• Stores term name, order, and creation timestamp
• Supports semester/term-based document organization
• Includes ordering for proper term sequencing

backend/go/internal/domain/academicterm/entity.go


51. backend/go/internal/domain/documenttype/entity.go ✨ Enhancement +14/-0

Document type domain entity

• New document type domain entity
• Stores type name, description, and creation timestamp
• Supports document categorization by type
• Enables filtering and organization

backend/go/internal/domain/documenttype/entity.go


52. backend/go/internal/domain/language/entity.go ✨ Enhancement +14/-0

Language domain entity

• New language domain entity
• Stores language code, name, and creation timestamp
• Supports document language classification
• Enables multi-language document support

backend/go/internal/domain/language/entity.go


53. backend/go/internal/domain/auditlog/repository.go ✨ Enhancement +10/-0

Audit log repository interface

• New audit log repository interface
• Supports creating audit log entries
• Provides FindAll() for retrieving audit history
• Enables action tracking and compliance

backend/go/internal/domain/auditlog/repository.go


54. backend/better-auth/index.ts ✨ Enhancement +211/-0

Hono authentication service with Swagger documentation

• New Hono authentication service entry point
• Implements CORS configuration for Next.js frontend
• Provides health check and Swagger documentation endpoints
• Routes all auth requests to Better Auth handler

backend/better-auth/index.ts


55. backend/better-auth/auth.ts ⚙️ Configuration changes +9/-4

Better Auth configuration with environment support

• Updated Better Auth configuration with environment variables
• Added trustedOrigins for frontend CORS support
• Changed base URL from port 3000 to 5000
• Imported dotenv for environment configuration

backend/better-auth/auth.ts


56. frontend/lib/auth-client.ts ⚙️ Configuration changes +1/-1

Auth client URL updated to separate service

• Updated auth client base URL from port 3000 to 5000
• Points to separate Hono authentication service
• Maintains username client plugin for authentication

frontend/lib/auth-client.ts


57. frontend/next-env.d.ts ⚙️ Configuration changes +1/-1

Next.js type definitions path update

• Updated Next.js types path from .next/dev/types to .next/types
• Aligns with newer Next.js version type generation

frontend/next-env.d.ts


58. docs/api_reference.md 📝 Documentation +207/-0

Complete API reference documentation

• Comprehensive API reference documentation in Vietnamese
• Documents authentication, system APIs, document operations, and admin endpoints
• Includes query parameters, request/response examples, and role-based access control
• Covers metadata lookups, document CRUD, reporting, and admin management

docs/api_reference.md


59. backend/go/migrations/001_initial.sql ✨ Enhancement +259/-0

Database schema with vector support and indexes

• Complete database schema initialization with pgvector extension
• Creates all tables including users, documents, chunks, chapters, and audit logs
• Implements Better Auth tables (session, account, verification)
• Includes comprehensive indexes for performance optimization

backend/go/migrations/001_initial.sql


60. backend/go/go.sum Dependencies +36/-0

AWS SDK v2 dependencies for S3 support

• Added AWS SDK v2 dependencies for S3 integration
• Includes S3 service, credentials, and configuration packages
• Adds supporting AWS packages for signing and protocol handling

backend/go/go.sum


61. backend/go/go.mod Dependencies +18/-0

AWS SDK v2 module dependencies

• Added AWS SDK v2 module dependencies
• Includes S3 service, credentials, config, and related packages
• Supports AWS authentication and S3 operations

backend/go/go.mod


62. backend/better-auth/package.json ⚙️ Configuration changes +24/-0

Hono authentication service package configuration

• New Node.js project configuration for Hono auth service
• Includes Better Auth, Hono, and PostgreSQL dependencies
• Provides dev and start scripts for running the service
• Configured with TypeScript support

backend/better-auth/package.json


63. backend/better-auth/tsconfig.json ⚙️ Configuration changes +11/-0

TypeScript configuration for auth service

• TypeScript configuration for Hono auth service
• Targets ES2022 with Node module resolution
• Enables strict type checking and ESM support

backend/better-auth/tsconfig.json


64. backend/start-backends.bat ⚙️ Configuration changes +3/-0

Backend startup script for concurrent services

• Batch script to start both Hono and Go backends concurrently
• Uses concurrently to manage multiple processes
• Provides colored output for process identification

backend/start-backends.bat


65. AGENTS.md Additional files +50/-38

...

AGENTS.md


66. backend/.env.example Additional files +0/-15

...

backend/.env.example


67. backend/better-auth/pnpm-lock.yaml Additional files +792/-0

...

backend/better-auth/pnpm-lock.yaml


68. backend/cmd/server/main.go Additional files +0/-46

...

backend/cmd/server/main.go


69. backend/docs/docs.go Additional files +0/-474

...

backend/docs/docs.go


70. backend/docs/swagger.json Additional files +0/-448

...

backend/docs/swagger.json


71. backend/docs/swagger.yaml Additional files +0/-285

...

backend/docs/swagger.yaml


72. backend/go/docs/swagger.json Additional files +2548/-0

...

backend/go/docs/swagger.json


73. backend/go/docs/swagger.yaml Additional files +1641/-0

...

backend/go/docs/swagger.yaml


74. backend/go/internal/infrastructure/chunker/chunker.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/chunker.go


75. backend/go/internal/infrastructure/chunker/chunker_test.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/chunker_test.go


76. backend/go/internal/infrastructure/chunker/hash.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/hash.go


77. backend/go/internal/infrastructure/chunker/overlap.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/overlap.go


78. backend/go/internal/infrastructure/chunker/splitter.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/splitter.go


79. backend/go/internal/infrastructure/chunker/tokenizer.go Additional files +0/-0

...

backend/go/internal/infrastructure/chunker/tokenizer.go


80. backend/go/internal/infrastructure/embedding/gemini-embedding_test.go Additional files +0/-0

...

backend/go/internal/infrastructure/embedding/gemini-embedding_test.go


81. backend/go/internal/infrastructure/embedding/provider.go Additional files +0/-0

...

backend/go/internal/infrastructure/embedding/provider.go


82. backend/go/internal/infrastructure/fileparser/docx-parser.go Additional files +0/-0

...

backend/go/internal/infrastructure/fileparser/docx-parser.go


83. backend/go/internal/infrastructure/fileparser/parser.go Additional files +0/-0

...

backend/go/internal/infrastructure/fileparser/parser.go


84. backend/go/internal/infrastructure/fileparser/parser_test.go Additional files +0/-0

...

backend/go/internal/infrastructure/fileparser/parser_test.go


85. backend/go/internal/infrastructure/fileparser/text-parser.go Additional files +0/-0

...

backend/go/internal/infrastructure/fileparser/text-parser.go


86. backend/go/internal/infrastructure/filestorage/local.go Additional files +0/-0

...

backend/go/internal/infrastructure/filestorage/local.go


87. backend/go/internal/interface/dto/request/upload.go Additional files +0/-0

...

backend/go/internal/interface/dto/request/upload.go


88. backend/go/internal/interface/dto/response/document.go Additional files +0/-0

...

backend/go/internal/interface/dto/response/document.go


89. backend/go/internal/interface/handler/health-handler.go Additional files +0/-0

...

backend/go/internal/interface/handler/health-handler.go


90. backend/go/pkg/database/postgres.go Additional files +0/-0

...

backend/go/pkg/database/postgres.go


91. backend/go/pkg/prompt/system_prompt.go Additional files +0/-0

...

backend/go/pkg/prompt/system_prompt.go


92. backend/internal/application/document-usecase/delete-document.go Additional files +0/-70

...

backend/internal/application/document-usecase/delete-document.go


93. backend/internal/application/document-usecase/errors.go Additional files +0/-5

...

backend/internal/application/document-usecase/errors.go


94. backend/internal/application/document-usecase/get-chunks.go Additional files +0/-21

...

backend/internal/application/document-usecase/get-chunks.go


95. backend/internal/application/document-usecase/get-document.go Additional files +0/-31

...

backend/internal/application/document-usecase/get-document.go


96. backend/internal/application/document-usecase/list-documents.go Additional files +0/-21

...

backend/internal/application/document-usecase/list-documents.go


97. backend/internal/application/document-usecase/upload.go Additional files +0/-113

...

backend/internal/application/document-usecase/upload.go


98. backend/internal/application/indexing/indexing.go Additional files +0/-231

...

backend/internal/application/indexing/indexing.go


99. backend/internal/application/indexing/indexing_test.go Additional files +0/-430

...

backend/internal/application/indexing/indexing_test.go


100. backend/internal/application/indexing/search.go Additional files +0/-122

...

backend/internal/application/indexing/search.go


101. backend/internal/application/indexing/search_test.go Additional files +0/-393

...

backend/internal/application/indexing/search_test.go


102. backend/internal/domain/chapter/entity.go Additional files +0/-15

...

backend/internal/domain/chapter/entity.go


103. backend/internal/domain/chapter/repository.go Additional files +0/-9

...

backend/internal/domain/chapter/repository.go


104. backend/internal/domain/chat-session/entity.go Additional files +0/-23

...

backend/internal/domain/chat-session/entity.go


105. backend/internal/domain/chat-session/repository.go Additional files +0/-11

...

backend/internal/domain/chat-session/repository.go


106. backend/internal/domain/chunk/entity.go Additional files +0/-18

...

backend/internal/domain/chunk/entity.go


107. backend/internal/domain/course/repository.go Additional files +0/-9

...

backend/internal/domain/course/repository.go


108. backend/internal/domain/document/entity.go Additional files +0/-31

...

backend/internal/domain/document/entity.go


109. backend/internal/domain/document/repository.go Additional files +0/-12

...

backend/internal/domain/document/repository.go


110. backend/internal/domain/message/entity.go Additional files +0/-37

...

backend/internal/domain/message/entity.go


111. backend/internal/domain/user/entity.go Additional files +0/-16

...

backend/internal/domain/user/entity.go


112. backend/internal/domain/user/repository.go Additional files +0/-9

...

backend/internal/domain/user/repository.go


113. backend/internal/infrastructure/repository/postgres/chunk-repository.go Additional files +0/-161

...

backend/internal/infrastructure/repository/postgres/chunk-repository.go


114. backend/internal/infrastructure/repository/postgres/document-repository.go Additional files +0/-121

...

<a href="https://github.com/UGing265/SWD392_Chatbot_RAG/pull/1/files#diff-41e718d49931e01b01be41671011da653647a0b4feb90...

@qodo-code-review

qodo-code-review Bot commented Jun 3, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (6) 📘 Rule violations (6) 🔗 Cross-repo conflicts (0)

Grey Divider


Action required

1. Hardcoded DATABASE_URL and JWT_SECRET 📘 Rule violation ⛨ Security
Description
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.
Code

backend/go/pkg/config/env.go[R37-48]

Evidence
PR Compliance ID 4 requires configuration to come from environment variables rather than hardcoded
values. The new Load() function embeds concrete defaults for DATABASE_URL, JWT_SECRET, and
other runtime settings.

AGENTS.md: Use Environment Variables for Configuration (No Hardcoded Config)
backend/go/pkg/config/env.go[37-48]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


2. DocumentService depends on infrastructure 📘 Rule violation ⚙ Maintainability
Description
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.
Code

backend/go/internal/application/document_service.go[R15-31]

Evidence
PR Compliance ID 5 requires dependencies to flow inward. The application service imports an
infrastructure package (internal/infrastructure/filestorage), creating an outward dependency from
application to infrastructure.

AGENTS.md: Go Backend Must Follow Clean Architecture Layering and Dependency Rules
backend/go/internal/application/document_service.go[15-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


3. document_service.go is a god file 📘 Rule violation ⚙ Maintainability
Description
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.
Code

backend/go/internal/application/document_service.go[R1-1659]

Evidence
PR Compliance ID 6 prohibits new/modified files exceeding 200 lines. The added document_service.go
spans through at least line 1659, demonstrating it greatly exceeds the limit.

AGENTS.md: Avoid God Files (Keep Files Focused and Under 200 Lines)
backend/go/internal/application/document_service.go[1-20]
backend/go/internal/application/document_service.go[1636-1659]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


View more (5)
4. GetMetadataLookups() ignores errors 📘 Rule violation ☼ Reliability
Description
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.
Code

backend/go/internal/interface/handler/document-handler.go[R594-600]

Evidence
PR Compliance ID 3 requires errors to be handled with meaningful messages rather than ignored. The
handler explicitly discards errors from GetSubjects, GetDocumentTypes, GetLanguages,
GetDocumentSources, and GetAcademicTerms.

AGENTS.md: Handle Errors with Meaningful Messages
backend/go/internal/interface/handler/document-handler.go[594-600]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


5. document-handler.go is a god file 📘 Rule violation ⚙ Maintainability
Description
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.
Code

backend/go/internal/interface/handler/document-handler.go[R1-608]

Evidence
PR Compliance ID 6 prohibits new/modified files exceeding 200 lines. This handler file extends
through line 608, demonstrating it exceeds the limit.

AGENTS.md: Avoid God Files (Keep Files Focused and Under 200 Lines)
backend/go/internal/interface/handler/document-handler.go[1-20]
backend/go/internal/interface/handler/document-handler.go[594-608]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


6. Auth strips token signature 📘 Rule violation ⛨ Security
Description
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.
Code

backend/go/internal/interface/middleware/auth.go[R39-42]

Evidence
PR Compliance ID 9 requires protected endpoints to enforce valid JWT authentication and Better Auth
token validation. The new code explicitly discards the signature portion of a token
(token.signature) by keeping only the prefix before ..

AGENTS.md: Protected Backend Routes Must Require Valid JWT and Go Backend Must Validate Better Auth Tokens
backend/go/internal/interface/middleware/auth.go[39-42]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


7. Visibility string bypass 🐞 Bug ⛨ Security
Description
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.
Code

backend/go/internal/application/document_service.go[R389-417]

Evidence
The handler accepts visibility from the request without validation, the service persists it
verbatim, access control only protects exact "private", and the public listing query excludes only
exact 'private', enabling bypass with non-canonical values.

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]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


8. Invalid CORS credentials 🐞 Bug ⛨ Security
Description
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.
Code

backend/go/internal/interface/router/router.go[R24-29]

Evidence
The router unconditionally sets wildcard origin and credentials, creating the invalid CORS header
combination on every request.

backend/go/internal/interface/router/router.go[24-29]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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



Remediation recommended

9. Upload size unchecked 🐞 Bug ⛨ Security
Description
MAX_FILE_SIZE exists in config and the upload handler passes file.Size into CreateDocument,
but CreateDocument never enforces any size limit (the size parameter is unused). This allows
oversized uploads that can waste CPU/bandwidth and increase DoS risk for the upload and hashing
paths.
Code

backend/go/internal/application/document_service.go[R366-373]

Evidence
Config defines and loads MAX_FILE_SIZE, the handler passes file.Size into the service, but the
service only streams the full reader into a hasher and never checks the size argument.

backend/go/pkg/config/env.go[7-49]
backend/go/internal/interface/handler/document-handler.go[197-299]
backend/go/internal/application/document_service.go[366-373]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
There is a configuration field for maximum upload size, but no code enforces it. The service method even receives the file size (`fileHeaderSize`) but does not use it.

## Issue Context
The upload flow computes an MD5 by streaming the entire file (`io.Copy`) and then uploads to S3; without an explicit cap, very large uploads are accepted and processed.

## Fix
- Validate `file.Size` in `DocumentHandler.Upload` against `cfg.MAX_FILE_SIZE` (you may need to inject config into the handler/service).
- Also enforce in `DocumentService.CreateDocument` (defense in depth) using the passed `fileHeaderSize` and/or wrapping the reader with `io.LimitReader`.
- Return a clear 413 (Payload Too Large) on violation.

## Fix Focus Areas
- backend/go/pkg/config/env.go[7-49]
- backend/go/internal/interface/handler/document-handler.go[197-299]
- backend/go/internal/application/document_service.go[366-373]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Auth ignores cancellation 🐞 Bug ☼ Reliability
Description
AuthMiddleware uses context.Background() for its DB query instead of the request context, so
request cancellation/timeouts won’t cancel the database call. Under load, this can hold connections
longer than necessary and degrade availability.
Code

backend/go/internal/interface/middleware/auth.go[R48-53]

Evidence
The middleware explicitly calls db.QueryRow(context.Background(), ...), disconnecting DB work from
the HTTP request lifecycle.

backend/go/internal/interface/middleware/auth.go[44-54]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The auth DB query is executed with a non-cancellable context, so it will keep running even if the client disconnects or the request times out.

## Issue Context
Gin exposes the request context via `c.Request.Context()`.

## Fix
- Replace `context.Background()` with `c.Request.Context()`.
- Optionally add a short timeout around auth queries (e.g., `context.WithTimeout`).

## Fix Focus Areas
- backend/go/internal/interface/middleware/auth.go[44-54]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Role assertion can panic 🐞 Bug ☼ Reliability
Description
RequireRoles and DocumentHandler.Details type-assert role_id to int16 without checking the
dynamic type; a mismatch will panic and crash the request handler. This is a brittle contract
between middleware and handlers.
Code

backend/go/internal/interface/middleware/auth.go[R89-99]

Evidence
Both the middleware helper and the handler use .(int16) assertions on values retrieved from Gin
context, which can panic if the dynamic type differs.

backend/go/internal/interface/middleware/auth.go[86-99]
backend/go/internal/interface/handler/document-handler.go[363-367]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Direct type assertions on `interface{}` values can panic if the stored type is not exactly `int16`.

## Issue Context
`role_id` is stored in Gin context in `AuthMiddleware`, then consumed in `RequireRoles` and in `DocumentHandler.Details`.

## Fix
- Use the comma-ok idiom: `roleID, ok := roleIDVal.(int16)` and handle `!ok` as unauthorized.
- Alternatively store role as a dedicated typed struct in context.

## Fix Focus Areas
- backend/go/internal/interface/middleware/auth.go[86-114]
- backend/go/internal/interface/handler/document-handler.go[363-367]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
12. parseInt silently wrong 🐞 Bug ≡ Correctness
Description
config.parseInt strips non-digits and silently turns invalid values into 0 (e.g., "50MB" becomes
50), producing incorrect configuration without errors. This can lead to surprising runtime behavior
when MAX_FILE_SIZE (or any future numeric env) is misconfigured.
Code

backend/go/pkg/config/env.go[R27-35]

Evidence
The implementation only accumulates digit runes and drops everything else, which changes the meaning
of common human-friendly values and hides invalid configuration.

backend/go/pkg/config/env.go[27-35]
backend/go/pkg/config/env.go[37-49]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The current integer parsing logic ignores non-digit characters and does not report errors, leading to unexpected values.

## Issue Context
`MAX_FILE_SIZE` is parsed from environment using this function.

## Fix
- Replace `parseInt` with `strconv.ParseInt` (base 10, 64-bit).
- If parsing fails, either:
 - log and fall back to the default, or
 - return an error from `Load()` and fail fast.
- If you want to support suffixes like `50MB`, implement explicit unit parsing rather than stripping characters.

## Fix Focus Areas
- backend/go/pkg/config/env.go[27-49]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@UGing265, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdf86ad8-930b-4cfa-9f94-7e95d22ab125

📥 Commits

Reviewing files that changed from the base of the PR and between ba46049 and 0e8481a.

📒 Files selected for processing (3)
  • backend/better-auth/auth.ts
  • backend/better-auth/index.ts
  • frontend/tsconfig.tsbuildinfo
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Auth service + Go RAG backend restructure

Layer / File(s) Summary
Better Auth (Hono) service
backend/better-auth/*
Hono server with CORS, health, Swagger, and /api/auth/* forwarding to Better Auth.
Go server bootstrap, Swagger, config, migrations
backend/go/cmd/server/main.go, backend/go/docs/*, backend/go/pkg/config/env.go, backend/go/migrations/*, backend/go/go.mod
Starts Go API, registers Swagger (JSON/YAML), loads env/config, and applies new DB schema.
Domain entities and repository contracts
backend/go/internal/domain/*
Defines entities and repository interfaces for documents, chunks, chapters, users, reports, sources, types, languages, subjects, academic terms, upload jobs, and audit logs.
PostgreSQL repository implementations
backend/go/internal/infrastructure/repository/postgres/*
Implements CRUD, listing, pagination, counts, and joins for all domain repositories.
Embedding, parsers, and S3 storage
backend/go/internal/infrastructure/*
Sets Gemini embeddings to 3072-d, enhances PDF/PPTX parsing, and adds S3 file storage.
Application service, HTTP handlers, middleware, router
backend/go/internal/application/document_service.go, backend/go/internal/interface/*
Implements DocumentService and HTTP handlers (public/lecturer/admin), auth/role middleware, and Gin router.
Background worker and chapter segmentation
backend/go/internal/infrastructure/worker/*, backend/go/internal/infrastructure/segmentation/*
Background job processes uploads: parse, chunk, embed, persist, segment chapters, and update state.
Documentation updates
AGENTS.md, docs/api_reference.md
Documents the decoupled architecture and provides a complete REST API reference.
Frontend auth wiring adjustments
frontend/*
Improves auth error handling, switches Better Auth baseURL default to :5000, and removes Next.js auth API route.
Local startup script
backend/start-backends.bat
Runs Auth and Go backends concurrently for development.
Legacy removals and pruning
backend/* (legacy paths)
Removes obsolete entrypoint and deprecated internal modules to align with new structure.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~150 minutes

Poem

A rabbit taps the keys with cheer,
Split the burrow: auth is here!
Go-winds hum on port eight-oh,
Hono guards on five-zero-zero.
S3 clouds and chapters bloom—
Swagger maps the daylight room.
Hop, run both; ship the zoom! 🐇✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/port-to-golang

Comment on lines +37 to +48
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", ""),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +15 to +31
"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"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +1 to +1659
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +594 to +600
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +1 to +608
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +39 to 42
// split token for Better Auth
if parts := strings.SplitN(tokenString, ".", 2); len(parts) == 2 {
tokenString = parts[0]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +389 to +417
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +24 to +29
// 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Remove 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 win

Save 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 lift

3072-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 lift

Split 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 win

Fix Gemini embedding retries reusing an exhausted request body
doRequest retries by calling req.Clone but never recreates req.Body. Since the initial request body is created from bytes.NewBuffer(jsonBody) and http.Client.Do consumes it, later retry attempts can send an empty/EOF payload. Recreate the body from req.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 win

Add a language to the fenced architecture block.

This block is missing a fence language, which will keep markdownlint complaining. text is 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 win

Add rows.Err() check after the for rows.Next() loop in FindByDocumentID.

After scanning rows and before returning files, handle any iteration-time DB error via if 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 win

Add a rows.Err() check after the rows.Next() loop in FindActiveByOwner (backend/go/internal/infrastructure/repository/postgres/uploadjob-repository.go).

rows.Next()/rows.Scan() errors during iteration won’t be surfaced unless you check rows.Err() after the loop; return that error before return 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 win

Propagate rows iteration errors in LanguageRepository.FindAll

FindAll returns langs, nil after the for rows.Next() loop without checking rows.Err(), which can silently drop scan/iteration failures—return rows.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 win

Handle terminal row-iteration errors after scanning loop.

FindByDocumentID returns without checking rows.Err() after the for rows.Next() loop (see backend/go/internal/infrastructure/repository/postgres/chapter-repository.go, lines 73-84), so any late driver/stream error can be silently ignored. Add a rows.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 win

Add rows.Err() checks after the rows.Next() loops in FindPending and FindByDocumentID.

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 win

Validate or remove unused MAX_FILE_SIZE parsing in backend/go/pkg/config/env.go.

  • parseInt silently drops non-digits, but Config.MAX_FILE_SIZE is only set in backend/go/pkg/config/env.go and is not referenced anywhere else in backend/go—file-size enforcement is done by backend/go/internal/infrastructure/filestorage/local.go’s hardcoded MaxFileSize (50MB).
  • Either remove the unused MAX_FILE_SIZE config/parser, or wire cfg.MAX_FILE_SIZE into the upload/file-storage flow; if you enable env-driven sizing, switch to strconv.ParseInt and propagate an explicit Load() 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

Comment on lines +39 to +53
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 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:


🏁 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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 || true

Repository: 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.

@UGing265

UGing265 commented Jun 3, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@UGing265

UGing265 commented Jun 3, 2026

Copy link
Copy Markdown
Owner Author

@claude review

@UGing265
UGing265 force-pushed the feat/port-to-golang branch from f971f64 to e81900f Compare June 3, 2026 15:10
@qodo-code-review

Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: claude-analysis

Failed stage: Run Claude Code CLI [❌]

Failed test name: ""

Failure summary:

The action failed because the claude CLI command exited with status code 1 during the step:
-
TERM=dumb claude --model claude-3-5-sonnet-20241022 ... > review_raw.md

The log does not include the underlying stderr/stdout from claude, so the exact root cause (e.g.,
API authentication/endpoint issue with ANTHROPIC_BASE_URL, model availability, network error, or CLI
usage error) cannot be determined from the provided snippet—only that the claude process failed.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

130:  yarn: 1.22.22
131:  ##[endgroup]
132:  ##[group]Run npm install -g @anthropic-ai/claude-code
133:  �[36;1mnpm install -g @anthropic-ai/claude-code�[0m
134:  shell: /usr/bin/bash -e {0}
135:  ##[endgroup]
136:  added 2 packages in 3s
137:  ##[group]Run TERM=dumb claude --model claude-3-5-sonnet-20241022 -p "Review code of the last commit or PR changes and identify any potential bugs or security issues" \
138:  �[36;1mTERM=dumb claude --model claude-3-5-sonnet-20241022 -p "Review code of the last commit or PR changes and identify any potential bugs or security issues" \�[0m
139:  �[36;1m       --allowedTools "Read,GrepTool" --dangerously-skip-permissions > review_raw.md�[0m
140:  shell: /usr/bin/bash -e {0}
141:  env:
142:  ANTHROPIC_BASE_URL: https://api.minimax.io/anthropic
143:  ANTHROPIC_API_KEY: ***
144:  ##[endgroup]
145:  ##[error]Process completed with exit code 1.
146:  Post job cleanup.

@UGing265
UGing265 merged commit 43e5614 into main Jun 3, 2026
1 check passed
@UGing265
UGing265 deleted the feat/port-to-golang branch June 23, 2026 04:37
@coderabbitai coderabbitai Bot mentioned this pull request Jul 14, 2026
Merged
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants