diff --git a/README.md b/README.md index 1799a87..8726d7c 100644 --- a/README.md +++ b/README.md @@ -1,224 +1,151 @@ -# Forgejo Classroom +# Class Forge -An educational assignment management system that integrates with Forgejo to provide GitHub Classroom-like functionality for self-hosted Git platforms. +An educational assignment management system that integrates with self-hosted +Git platforms (Forgejo and GitLab) to provide GitHub Classroom-like +functionality. -## Project Status +## Features -🚧 **This project is in early development** - Most functionality is not yet implemented. This repository contains the initial project structure and API framework. - -## Features (Planned) - -- **Classroom Management**: Create and manage coding classrooms -- **Assignment Distribution**: Create assignments from template repositories -- **Student Management**: Manage student rosters and account linking -- **Team Support**: Support for both individual and team assignments -- **Submission Tracking**: Automatic tracking of student submissions -- **Deadline Enforcement**: Automatic deadline management with git tags -- **CLI & API**: Complete CLI tool and REST API +- **Classroom Management** - Create and manage coding classrooms tied to platform organizations/groups +- **Assignment Distribution** - Create assignments from template repositories with automatic repo provisioning +- **Student Management** - Manage student rosters and link to platform accounts +- **Team Support** - Individual and team-based assignments with size limits +- **Submission Tracking** - Automatic tracking when students accept assignments +- **Deadline Enforcement** - Configurable deadlines per assignment +- **Multi-Platform** - Supports both Forgejo and GitLab backends through a unified platform abstraction +- **REST API** - Complete REST API for all operations ## Architecture -This project follows a clean architecture with clear separation of concerns: +Class Forge follows a layered architecture with clear separation of concerns: + +``` +API Handlers -> Service Layer -> Repository Layer -> PostgreSQL + | + Platform Provider (Forgejo / GitLab) +``` -- **CLI Application** (`cmd/fgc/`) - Command-line interface for all operations -- **API Server** (`cmd/fgc-server/`) - REST API for web integrations -- **Service Layer** (`internal/service/`) - Business logic -- **Repository Layer** (`internal/repository/`) - Data access +- **API Server** (`cmd/fgc-server/`) - REST API with Gin framework +- **CLI Tool** (`cmd/fgc/`) - Command-line interface +- **Service Layer** (`internal/service/`) - Business logic and validation +- **Repository Layer** (`internal/repository/`) - Data access with raw SQL +- **Platform Abstraction** (`internal/platform/`) - Provider interface for Git platforms - **Models** (`internal/model/`) - Domain objects -- **Forgejo Integration** (`internal/forgejo/`) - Forgejo API client ## Requirements - Go 1.21+ - PostgreSQL 14+ -- Redis 7+ -- Docker & Docker Compose (for development) -- Forgejo instance with API access +- A Forgejo or GitLab instance with API access ## Quick Start -### 1. Clone and Setup +### 1. Build ```bash -git clone -cd forgejo-classroom - -# Copy configuration examples -cp config.yaml.example config.yaml -cp .env.example .env - -# Edit configuration files with your settings +git clone https://code.forgejo.org/forgejo/classroom.git +cd classroom +make build ``` -### 2. Development Environment +### 2. Configure -```bash -# Install dependencies -make deps +Create `config.yaml`: -# Start development services (PostgreSQL + Redis) -make docker-dev-up +```yaml +server: + port: 8080 + mode: release -# Build the applications -make build +platform: + type: forgejo # or "gitlab" -# Run tests -make test +forgejo: + base_url: https://your-forgejo.example.com + token: your-api-token + +database: + host: localhost + port: 5432 + user: classforge + password: secret + name: classforge + ssl_mode: disable ``` -### 3. CLI Usage +Or use environment variables with the `FGC_` prefix (e.g., `FGC_PLATFORM_TYPE=forgejo`). -```bash -# Build and test CLI -make build-cli +### 3. Run -# Show help -./bin/fgc --help +```bash +# Set up PostgreSQL database +createdb classforge -# Example commands (not yet implemented): -./bin/fgc classroom create "CS 101" --org="university-cs" -./bin/fgc assignment create "Homework 1" --classroom="cs-101" --template="https://your-forgejo.com/templates/hw1" +# Start the server (migrations run automatically) +./bin/fgc-server ``` -### 4. API Server +### 4. Verify ```bash -# Build and run API server -make build-server -./bin/fgc-server - -# Or with Docker -make docker-build -docker-compose up +curl http://localhost:8080/healthz +# {"status":"ok"} ``` +See the [Getting Started Guide](docs/getting-started.md) for a complete walkthrough. + +## Documentation + +- [Getting Started](docs/getting-started.md) - Installation, configuration, first classroom +- [Configuration Reference](docs/configuration.md) - All config options and environment variables +- [API Reference](docs/api-reference.md) - Complete REST API documentation +- [Technical Design](design.md) - Architecture, schemas, and implementation strategy + ## Development ### Project Structure ``` -forgejo-classroom/ -├── cmd/ # Application entry points -│ ├── fgc/ # CLI application -│ └── fgc-server/ # API server -├── internal/ # Private application code -│ ├── api/ # API handlers and routing -│ ├── service/ # Business logic -│ ├── repository/ # Data access layer -│ ├── model/ # Domain models -│ ├── forgejo/ # Forgejo integration -│ ├── cache/ # Caching layer -│ ├── config/ # Configuration -│ └── util/ # Utilities -├── pkg/ # Public libraries -├── migrations/ # Database migrations -├── test/ # Integration tests -└── docs/ # Documentation +class-forge/ + cmd/ + fgc/ CLI application + fgc-server/ API server + internal/ + api/ HTTP handlers and routing + config/ Configuration loading + database/ Database connection and migrations + model/ Domain models + platform/ Git platform abstraction + forgejo/ Forgejo provider + gitlab/ GitLab provider + repository/ Data access layer (SQL) + service/ Business logic + util/ Shared utilities + migrations/ PostgreSQL migration files + docs/ User documentation ``` -### Development Workflow - -This project uses **Jujutsu (jj)** for version control. See `CLAUDE.md` for complete development guidelines. +### Running Tests ```bash -# Create new change -jj new -m "Add feature description" - -# Run tests before committing +# All unit tests make test -# Describe your change -jj describe -m "Detailed commit message" +# Specific package +go test ./internal/service/... -v -# Push to remote -jj git push +# With coverage +go test ./... -cover ``` -### Available Make Targets +### Make Targets -- `make build` - Build CLI and server -- `make test` - Run all tests -- `make test-integration` - Run integration tests +- `make build` - Build CLI and server binaries +- `make test` - Run unit tests +- `make test-integration` - Run integration tests (requires Docker) - `make lint` - Run linters -- `make docker-test-up` - Start test environment -- `make dev-setup` - Set up development environment -- `make clean` - Clean build artifacts - -## Configuration - -### Environment Variables - -Key environment variables (see `.env.example`): - -- `FGC_FORGEJO_BASE_URL` - Your Forgejo instance URL -- `FGC_FORGEJO_TOKEN` - Forgejo API token -- `FGC_DATABASE_*` - Database connection settings -- `FGC_REDIS_*` - Redis connection settings - -### Configuration File - -See `config.yaml.example` for full configuration options. - -## API Documentation - -API documentation is available at `/api/v1` when running the server. The complete OpenAPI specification is documented in `design.md`. - -## Testing - -```bash -# Unit tests -make test-unit - -# Integration tests (requires Docker) -make test-integration - -# All tests -make test - -# Coverage report -make coverage -``` - -## Contributing - -Please read `CONTRIBUTING.md` and `CLAUDE.md` for development guidelines. - -1. Understand the architecture in `design.md` -2. Follow the development workflow in `CLAUDE.md` -3. Write tests for new functionality -4. Ensure all tests pass before submitting changes +- `make clean` - Remove build artifacts ## License [License TBD] - -## Roadmap - -See `design.md` for detailed implementation phases and roadmap. - -### Phase 1: Core MVP (In Progress) -- ✅ Project structure and build system -- ✅ CLI framework -- ✅ API framework -- 🔄 Database layer -- 🔄 Forgejo integration -- ⏳ Basic classroom management -- ⏳ Assignment creation - -### Phase 2: Assignment Distribution -- ⏳ Template repository handling -- ⏳ Student repository creation -- ⏳ Assignment acceptance flow - -### Phase 3: Advanced Features -- ⏳ Team support -- ⏳ Deadline enforcement -- ⏳ Submission tracking -- ⏳ Statistics and reporting - -## Support - -For questions and support: -- Check the documentation in `docs/` -- Review the technical design in `design.md` -- Create an issue for bugs or feature requests \ No newline at end of file diff --git a/cmd/fgc-server/main.go b/cmd/fgc-server/main.go index 9e634ae..d9db9b5 100644 --- a/cmd/fgc-server/main.go +++ b/cmd/fgc-server/main.go @@ -17,6 +17,10 @@ import ( "code.forgejo.org/forgejo/classroom/internal/api" "code.forgejo.org/forgejo/classroom/internal/config" "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/platform/forgejo" + "code.forgejo.org/forgejo/classroom/internal/platform/gitlab" + "code.forgejo.org/forgejo/classroom/internal/service" ) var ( @@ -38,7 +42,7 @@ func main() { } defer logger.Sync() - logger.Info("Starting Forgejo Classroom Server", + logger.Info("Starting Class Forge Server", zap.String("version", version), zap.String("commit", commit), zap.String("build_date", date), @@ -69,26 +73,32 @@ func main() { } // Get current migration version - version, dirty, err := database.GetMigrationVersion(db.DB, migrateConfig, logger) + migVer, dirty, err := database.GetMigrationVersion(db.DB, migrateConfig, logger) if err != nil { logger.Warn("Failed to get migration version", zap.Error(err)) } else { logger.Info("Database migration status", - zap.Uint("version", version), + zap.Uint("version", migVer), zap.Bool("dirty", dirty), ) } - // TODO: Initialize cache - // TODO: Initialize Forgejo client - // TODO: Initialize services + // Initialize platform provider + provider, err := initPlatformProvider(cfg, logger) + if err != nil { + logger.Fatal("Failed to initialize platform provider", zap.Error(err)) + } + logger.Info("Platform provider initialized", zap.String("type", string(provider.Type()))) + + // Initialize services + services := service.NewServices(db, provider, logger) // Initialize Gin router if cfg.Server.Mode == "release" { gin.SetMode(gin.ReleaseMode) } - router := api.NewRouter(cfg, logger) + router := api.NewRouter(services, logger) // Create HTTP server srv := &http.Server{ @@ -125,6 +135,29 @@ func main() { logger.Info("Server exited") } +func initPlatformProvider(cfg *config.Config, logger *zap.Logger) (platform.Provider, error) { + switch cfg.Platform.Type { + case "forgejo": + return forgejo.New(platform.ProviderConfig{ + BaseURL: cfg.Forgejo.BaseURL, + Token: cfg.Forgejo.Token, + TimeoutSeconds: int(cfg.Forgejo.Timeout.Seconds()), + RequestsPerMinute: cfg.Forgejo.RateLimit.RequestsPerMinute, + BurstSize: cfg.Forgejo.RateLimit.BurstSize, + }) + case "gitlab": + return gitlab.New(platform.ProviderConfig{ + BaseURL: cfg.GitLab.BaseURL, + Token: cfg.GitLab.Token, + TimeoutSeconds: int(cfg.GitLab.Timeout.Seconds()), + RequestsPerMinute: cfg.GitLab.RateLimit.RequestsPerMinute, + BurstSize: cfg.GitLab.RateLimit.BurstSize, + }) + default: + return nil, fmt.Errorf("unsupported platform type: %s", cfg.Platform.Type) + } +} + func initConfig() error { // Set defaults viper.SetDefault("server.port", 8080) @@ -140,8 +173,8 @@ func initConfig() error { viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath(".") - viper.AddConfigPath("/etc/forgejo-classroom/") - viper.AddConfigPath("$HOME/.config/forgejo-classroom/") + viper.AddConfigPath("/etc/class-forge/") + viper.AddConfigPath("$HOME/.config/class-forge/") if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { @@ -153,13 +186,13 @@ func initConfig() error { } func initLogger() (*zap.Logger, error) { - var config zap.Config + var zapCfg zap.Config if viper.GetString("server.mode") == "release" { - config = zap.NewProductionConfig() + zapCfg = zap.NewProductionConfig() } else { - config = zap.NewDevelopmentConfig() + zapCfg = zap.NewDevelopmentConfig() } - return config.Build() + return zapCfg.Build() } diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..5e3707e --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,616 @@ +# API Reference + +Class Forge exposes a REST API under the `/api/v1` prefix. All request and +response bodies use JSON. + +## Health Check + +### GET /healthz + +Returns the server health status. + +**Response** `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Classrooms + +### POST /api/v1/classrooms + +Create a new classroom. The organization (Forgejo) or group (GitLab) +must already exist on the platform. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Classroom name (1-255 chars) | +| `description` | string | no | Classroom description | +| `organization_name` | string | yes | Platform organization/group name | +| `public` | boolean | no | Whether the classroom is publicly visible (default: false) | + +**Response** `201 Created` + +```json +{ + "id": 1, + "name": "Introduction to CS", + "slug": "introduction-to-cs", + "description": "CS 101", + "organization_id": 42, + "public": false, + "archived": false, + "created_at": "2026-03-28T10:00:00Z", + "updated_at": "2026-03-28T10:00:00Z" +} +``` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | VALIDATION_ERROR | Missing or invalid fields | +| 409 | RESOURCE_ALREADY_EXISTS | Classroom with same slug exists | +| 502 | INTEGRATION_PLATFORM_API_ERROR | Platform API call failed | + +--- + +### GET /api/v1/classrooms + +List all classrooms with pagination. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` + +```json +{ + "classrooms": [...], + "total": 15, + "page": 1, + "per_page": 20, + "total_pages": 1 +} +``` + +--- + +### GET /api/v1/classrooms/:id + +Get a classroom by ID. + +**Response** `200 OK` - Classroom object + +**Errors** `404` if not found + +--- + +### PUT /api/v1/classrooms/:id + +Update a classroom. Only provided fields are modified. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | no | New name | +| `description` | string | no | New description | +| `public` | boolean | no | New visibility | + +**Response** `200 OK` - Updated classroom object + +--- + +### DELETE /api/v1/classrooms/:id + +Delete a classroom. + +**Response** `204 No Content` + +--- + +### POST /api/v1/classrooms/:id/archive + +Archive a classroom. Archived classrooms are read-only. + +**Response** `200 OK` - Archived classroom object + +--- + +### GET /api/v1/classrooms/:id/stats + +Get classroom statistics. + +**Response** `200 OK` + +```json +{ + "classroom_id": 1, + "assignment_count": 5, + "student_count": 30, + "submission_count": 120 +} +``` + +--- + +## Assignments + +### POST /api/v1/classrooms/:classroomId/assignments + +Create a new assignment in a classroom. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Assignment name (1-255 chars) | +| `description` | string | no | Assignment description | +| `template_repository` | string | yes | Template repo in `owner/name` format | +| `max_team_size` | integer | no | Max team size (default: 1 for individual) | +| `deadline` | string (RFC3339) | no | Submission deadline | + +**Response** `201 Created` + +```json +{ + "id": 1, + "classroom_id": 1, + "name": "Homework 1", + "slug": "homework-1", + "description": "Implement a linked list", + "template_repository_id": 100, + "max_team_size": 1, + "deadline": "2026-04-15T23:59:59Z", + "created_at": "2026-03-28T10:00:00Z", + "updated_at": "2026-03-28T10:00:00Z" +} +``` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | VALIDATION_ERROR | Missing or invalid fields | +| 404 | RESOURCE_NOT_FOUND | Classroom does not exist | +| 409 | RESOURCE_ALREADY_EXISTS | Assignment with same slug exists in classroom | + +--- + +### GET /api/v1/classrooms/:classroomId/assignments + +List assignments in a classroom. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` + +```json +{ + "assignments": [...], + "total": 5, + "page": 1, + "per_page": 20, + "total_pages": 1 +} +``` + +--- + +### GET /api/v1/assignments/:id + +Get an assignment by ID. + +**Response** `200 OK` - Assignment object + +--- + +### PUT /api/v1/assignments/:id + +Update an assignment. Only provided fields are modified. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | no | New name | +| `description` | string | no | New description | +| `max_team_size` | integer | no | New max team size | +| `deadline` | string (RFC3339) | no | New deadline | + +**Response** `200 OK` - Updated assignment object + +--- + +### DELETE /api/v1/assignments/:id + +Delete an assignment. + +**Response** `204 No Content` + +--- + +### GET /api/v1/assignments/:id/stats + +Get assignment statistics. + +**Response** `200 OK` + +```json +{ + "assignment_id": 1, + "submission_count": 25, + "team_count": 5, + "accepted_count": 20 +} +``` + +--- + +## Roster (Student Management) + +### POST /api/v1/classrooms/:classroomId/roster + +Add a student to a classroom roster. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `student_id` | string | yes | Institutional student identifier | +| `name` | string | yes | Student full name | +| `email` | string | yes | Student email address | + +**Response** `201 Created` + +```json +{ + "id": 1, + "classroom_id": 1, + "student_id": "alice123", + "name": "Alice Smith", + "email": "alice@university.edu", + "platform_username": "", + "platform_user_id": 0, + "created_at": "2026-03-28T10:00:00Z", + "updated_at": "2026-03-28T10:00:00Z" +} +``` + +--- + +### GET /api/v1/classrooms/:classroomId/roster + +List students in a classroom roster. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` + +```json +{ + "students": [...], + "total": 30, + "page": 1, + "per_page": 20, + "total_pages": 2 +} +``` + +--- + +### PUT /api/v1/classrooms/:classroomId/roster/:studentId/link + +Link a roster entry to a platform account. This enables the student +to accept assignments and receive repository access. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `platform_username` | string | yes | Username on the Git platform | + +**Response** `200 OK` - Updated roster entry + +--- + +### DELETE /api/v1/classrooms/:classroomId/roster/:studentId + +Remove a student from the roster. + +**Response** `204 No Content` + +--- + +## Submissions + +### POST /api/v1/assignments/:assignmentId/submissions + +Accept an assignment for a student. This creates a repository from the +template and grants the student access. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `student_id` | integer | yes | Roster entry ID of the student | +| `team_id` | integer | no | Team ID (for team assignments) | + +**Response** `201 Created` + +```json +{ + "id": 1, + "assignment_id": 1, + "student_id": 1, + "repository_id": 999, + "repository_url": "https://forgejo.example.com/org/hw1-alice-smith.git", + "status": "active", + "created_at": "2026-03-28T10:05:00Z" +} +``` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | BUSINESS_DEADLINE_PASSED | Assignment deadline has passed | +| 404 | RESOURCE_NOT_FOUND | Assignment or student not found | +| 409 | RESOURCE_ALREADY_EXISTS | Student already accepted this assignment | + +--- + +### GET /api/v1/submissions/:id + +Get a submission by ID. + +**Response** `200 OK` - Submission object + +--- + +### GET /api/v1/assignments/:assignmentId/submissions + +List submissions for an assignment. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` + +```json +{ + "submissions": [...], + "total": 25, + "page": 1, + "per_page": 20, + "total_pages": 2 +} +``` + +--- + +### GET /api/v1/classrooms/:classroomId/submissions + +List all submissions across all assignments in a classroom. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` - Same format as above + +--- + +## Teams + +### POST /api/v1/assignments/:assignmentId/teams + +Create a team for a team-based assignment. The creator becomes the +team leader. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Team name (1-255 chars) | +| `description` | string | no | Team description | +| `leader_student_id` | integer | yes | Roster entry ID of the team leader | + +**Response** `201 Created` + +```json +{ + "id": 1, + "assignment_id": 2, + "name": "Team Alpha", + "slug": "team-alpha", + "description": "", + "leader_id": 1, + "member_count": 1, + "created_at": "2026-03-28T10:00:00Z" +} +``` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | BUSINESS_OPERATION_NOT_ALLOWED | Assignment is individual-only | +| 404 | RESOURCE_NOT_FOUND | Assignment not found | +| 409 | RESOURCE_ALREADY_EXISTS | Team with same slug exists | + +--- + +### GET /api/v1/teams/:id + +Get a team with its member list. + +**Response** `200 OK` + +```json +{ + "team": { + "id": 1, + "assignment_id": 2, + "name": "Team Alpha", + "slug": "team-alpha", + "leader_id": 1, + "member_count": 3 + }, + "members": [ + {"student_id": 1, "role": "leader", "name": "Alice Smith"}, + {"student_id": 2, "role": "member", "name": "Bob Jones"}, + {"student_id": 3, "role": "member", "name": "Carol Lee"} + ] +} +``` + +--- + +### POST /api/v1/teams/:id/join + +Add a student to a team. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `student_id` | integer | yes | Roster entry ID of the student | + +**Response** `200 OK` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | BUSINESS_TEAM_SIZE_EXCEEDED | Team is full | +| 409 | RESOURCE_ALREADY_EXISTS | Student is already a member | + +--- + +### POST /api/v1/teams/:id/leave + +Remove a student from a team. Team leaders cannot leave; they must +delete the team instead. + +**Request Body** + +| Field | Type | Required | Description | +|---|---|---|---| +| `student_id` | integer | yes | Roster entry ID of the student | + +**Response** `200 OK` + +**Errors** + +| Status | Code | Cause | +|---|---|---| +| 400 | BUSINESS_OPERATION_NOT_ALLOWED | Team leader cannot leave | + +--- + +### DELETE /api/v1/teams/:id + +Delete a team and remove all members. + +**Response** `204 No Content` + +--- + +### GET /api/v1/assignments/:assignmentId/teams + +List teams for an assignment. + +**Query Parameters** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `page` | integer | 1 | Page number | +| `per_page` | integer | 20 | Items per page (max 100) | + +**Response** `200 OK` + +```json +{ + "teams": [...], + "total": 8, + "page": 1, + "per_page": 20, + "total_pages": 1 +} +``` + +--- + +## Error Responses + +All error responses follow a consistent format: + +```json +{ + "error": { + "code": "RESOURCE_NOT_FOUND", + "message": "classroom 42 not found" + } +} +``` + +### Error Codes + +| Code | HTTP Status | Description | +|---|---|---| +| `VALIDATION_ERROR` | 400 | Request validation failed | +| `RESOURCE_NOT_FOUND` | 404 | Requested resource does not exist | +| `RESOURCE_ALREADY_EXISTS` | 409 | Resource with same identifier exists | +| `BUSINESS_OPERATION_NOT_ALLOWED` | 400 | Operation not permitted by business rules | +| `BUSINESS_DEADLINE_PASSED` | 400 | Assignment deadline has passed | +| `BUSINESS_TEAM_SIZE_EXCEEDED` | 400 | Team has reached maximum size | +| `BUSINESS_ROSTER_NOT_FOUND` | 400 | Student not in classroom roster | +| `INTEGRATION_PLATFORM_API_ERROR` | 502 | Git platform API call failed | +| `INTEGRATION_PLATFORM_RATE_LIMITED` | 429 | Platform API rate limit exceeded | +| `INTEGRATION_PLATFORM_UNAVAILABLE` | 503 | Platform is temporarily unavailable | + +### Validation Errors + +Validation errors include field-level details: + +```json +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "validation failed", + "details": [ + {"field": "name", "message": "Name is required"}, + {"field": "email", "message": "Email must be a valid email address"} + ] + } +} +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..fcb0be9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,179 @@ +# Configuration Reference + +Class Forge is configured through a YAML file and/or environment variables. +Environment variables use the `FGC_` prefix and take precedence over file values. + +## Configuration File Locations + +The server looks for `config.yaml` in these locations (in order): + +1. Current working directory (`./config.yaml`) +2. `/etc/class-forge/config.yaml` +3. `$HOME/.config/class-forge/config.yaml` + +## Full Configuration Example + +```yaml +server: + port: 8080 + mode: release # "debug" or "release" + read_timeout: 30 # seconds + write_timeout: 30 # seconds + +platform: + type: forgejo # "forgejo" or "gitlab" + +forgejo: + base_url: https://forgejo.example.com + token: your-forgejo-api-token + timeout: 30s + rate_limit: + requests_per_minute: 60 + burst_size: 10 + +gitlab: + base_url: https://gitlab.example.com + token: your-gitlab-api-token + timeout: 30s + rate_limit: + requests_per_minute: 60 + burst_size: 10 + +database: + host: localhost + port: 5432 + user: classforge + password: secret + name: classforge + ssl_mode: disable # disable, require, verify-ca, verify-full + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 300 # seconds + +redis: + host: localhost + port: 6379 + password: "" + db: 0 +``` + +## Environment Variables + +Every configuration key can be set via an environment variable. The mapping +follows the pattern `FGC_
_` with underscores separating +nested keys. + +### Server + +| Variable | Default | Description | +|---|---|---| +| `FGC_SERVER_PORT` | `8080` | HTTP server listen port | +| `FGC_SERVER_MODE` | `debug` | Gin mode: `debug` or `release` | +| `FGC_SERVER_READ_TIMEOUT` | `30` | HTTP read timeout in seconds | +| `FGC_SERVER_WRITE_TIMEOUT` | `30` | HTTP write timeout in seconds | + +### Platform + +| Variable | Default | Description | +|---|---|---| +| `FGC_PLATFORM_TYPE` | (required) | Git platform backend: `forgejo` or `gitlab` | + +### Forgejo + +Required when `platform.type` is `forgejo`. + +| Variable | Default | Description | +|---|---|---| +| `FGC_FORGEJO_BASE_URL` | (required) | Forgejo instance URL | +| `FGC_FORGEJO_TOKEN` | (required) | Forgejo API token with admin/owner scope | +| `FGC_FORGEJO_TIMEOUT` | `30s` | API request timeout | +| `FGC_FORGEJO_RATE_LIMIT_REQUESTS_PER_MINUTE` | `60` | Max API requests per minute | +| `FGC_FORGEJO_RATE_LIMIT_BURST_SIZE` | `10` | Max burst of concurrent requests | + +### GitLab + +Required when `platform.type` is `gitlab`. + +| Variable | Default | Description | +|---|---|---| +| `FGC_GITLAB_BASE_URL` | (required) | GitLab instance URL | +| `FGC_GITLAB_TOKEN` | (required) | GitLab personal access token | +| `FGC_GITLAB_TIMEOUT` | `30s` | API request timeout | +| `FGC_GITLAB_RATE_LIMIT_REQUESTS_PER_MINUTE` | `60` | Max API requests per minute | +| `FGC_GITLAB_RATE_LIMIT_BURST_SIZE` | `10` | Max burst of concurrent requests | + +### Database + +| Variable | Default | Description | +|---|---|---| +| `FGC_DATABASE_HOST` | `localhost` | PostgreSQL hostname | +| `FGC_DATABASE_PORT` | `5432` | PostgreSQL port | +| `FGC_DATABASE_USER` | (required) | Database username | +| `FGC_DATABASE_PASSWORD` | (required) | Database password | +| `FGC_DATABASE_NAME` | (required) | Database name | +| `FGC_DATABASE_SSL_MODE` | `disable` | SSL mode for database connection | +| `FGC_DATABASE_MAX_OPEN_CONNS` | `25` | Maximum open connections | +| `FGC_DATABASE_MAX_IDLE_CONNS` | `5` | Maximum idle connections | +| `FGC_DATABASE_CONN_MAX_LIFETIME` | `300` | Connection max lifetime in seconds | + +### Redis + +| Variable | Default | Description | +|---|---|---| +| `FGC_REDIS_HOST` | `localhost` | Redis hostname | +| `FGC_REDIS_PORT` | `6379` | Redis port | +| `FGC_REDIS_PASSWORD` | `""` | Redis password (empty for no auth) | +| `FGC_REDIS_DB` | `0` | Redis database number | + +## Platform Setup + +### Forgejo + +1. Log into your Forgejo instance as an administrator +2. Go to **Settings > Applications > Generate New Token** +3. Grant the token these scopes: + - `repo` (full repository access) + - `admin:org` (organization management) + - `user` (user information) +4. Create an organization for your classroom (e.g., `cs-department`) +5. Set the token and base URL in your configuration + +### GitLab + +1. Log into your GitLab instance +2. Go to **User Settings > Access Tokens** +3. Create a personal access token with these scopes: + - `api` (full API access) + - `read_user` (read user information) +4. Create a group for your classroom (e.g., `cs-department`) +5. Set the token and base URL in your configuration + +Note: For self-hosted GitLab, use your instance URL (e.g., +`https://gitlab.myuniversity.edu`). For GitLab.com, use +`https://gitlab.com`. + +## Database Migrations + +Migrations run automatically on server startup. The migration files are +located in the `migrations/` directory. The server applies any pending +migrations before accepting requests. + +To check the current migration version, look for the log line: + +``` +Database migration status {"version": 6, "dirty": false} +``` + +If a migration fails (dirty state), you may need to manually fix the +database and reset the migration version. See the +[golang-migrate documentation](https://github.com/golang-migrate/migrate) +for details. + +## Production Recommendations + +- Set `server.mode` to `release` to disable debug logging +- Use `ssl_mode: verify-full` for database connections +- Set strong, unique passwords for database and Redis +- Use a reverse proxy (nginx, Caddy) for TLS termination +- Set appropriate rate limits for your platform API tokens +- Monitor the `/healthz` endpoint for uptime checks diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..fd4a2dd --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,260 @@ +# Getting Started with Class Forge + +Class Forge is an educational assignment management system that integrates with +self-hosted Git platforms (Forgejo and GitLab) to provide GitHub Classroom-like +functionality. + +## Prerequisites + +- **Go 1.21+** (for building from source) +- **PostgreSQL 14+** (database) +- **A Forgejo or GitLab instance** with API access and an admin/owner token +- **Docker & Docker Compose** (optional, for development) + +## Installation + +### Build from Source + +```bash +git clone https://code.forgejo.org/forgejo/classroom.git +cd classroom + +# Build both the CLI and server +make build + +# Binaries are placed in ./bin/ +ls bin/ +# fgc (CLI tool) +# fgc-server (API server) +``` + +### Docker + +```bash +docker-compose up -d +``` + +## Configuration + +Class Forge reads configuration from a YAML file and environment variables. +Environment variables use the `FGC_` prefix and override file values. + +Create a `config.yaml` in one of the following locations: + +- Current working directory (`./config.yaml`) +- `/etc/class-forge/config.yaml` +- `$HOME/.config/class-forge/config.yaml` + +### Minimal Configuration (Forgejo) + +```yaml +server: + port: 8080 + mode: release # "debug" for development + +platform: + type: forgejo + +forgejo: + base_url: https://your-forgejo-instance.example.com + token: your-api-token-here + +database: + host: localhost + port: 5432 + user: classforge + password: secret + name: classforge + ssl_mode: disable +``` + +### Minimal Configuration (GitLab) + +```yaml +server: + port: 8080 + mode: release + +platform: + type: gitlab + +gitlab: + base_url: https://gitlab.example.com + token: your-gitlab-token-here + +database: + host: localhost + port: 5432 + user: classforge + password: secret + name: classforge + ssl_mode: disable +``` + +See [Configuration Reference](configuration.md) for all options. + +## Database Setup + +1. Create a PostgreSQL database: + +```sql +CREATE USER classforge WITH PASSWORD 'secret'; +CREATE DATABASE classforge OWNER classforge; +``` + +2. Migrations run automatically when the server starts. The migration files + are in the `migrations/` directory and are applied in order. + +## Starting the Server + +```bash +# Using config.yaml in the current directory +./bin/fgc-server + +# Or with environment variables +FGC_SERVER_PORT=8080 \ +FGC_PLATFORM_TYPE=forgejo \ +FGC_FORGEJO_BASE_URL=https://forgejo.example.com \ +FGC_FORGEJO_TOKEN=your-token \ +FGC_DATABASE_HOST=localhost \ +FGC_DATABASE_PORT=5432 \ +FGC_DATABASE_USER=classforge \ +FGC_DATABASE_PASSWORD=secret \ +FGC_DATABASE_NAME=classforge \ +./bin/fgc-server +``` + +The server starts on the configured port (default 8080). Verify it is running: + +```bash +curl http://localhost:8080/healthz +# {"status":"ok"} +``` + +## Your First Classroom + +### 1. Create a Classroom + +A classroom is tied to an organization (Forgejo) or group (GitLab) on your +Git platform. The organization must already exist. + +```bash +curl -X POST http://localhost:8080/api/v1/classrooms \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Introduction to Computer Science", + "description": "CS 101 - Fall 2026", + "organization_name": "cs-department", + "public": false + }' +``` + +Response: + +```json +{ + "id": 1, + "name": "Introduction to Computer Science", + "slug": "introduction-to-computer-science", + "description": "CS 101 - Fall 2026", + "organization_id": 42, + "public": false, + "archived": false, + "created_at": "2026-03-28T10:00:00Z", + "updated_at": "2026-03-28T10:00:00Z" +} +``` + +### 2. Add Students to the Roster + +```bash +curl -X POST http://localhost:8080/api/v1/classrooms/1/roster \ + -H "Content-Type: application/json" \ + -d '{ + "student_id": "alice123", + "name": "Alice Smith", + "email": "alice@university.edu" + }' +``` + +### 3. Link Students to Platform Accounts + +Once a student has an account on the Git platform, link it: + +```bash +curl -X PUT http://localhost:8080/api/v1/classrooms/1/roster/1/link \ + -H "Content-Type: application/json" \ + -d '{ + "platform_username": "alice-smith" + }' +``` + +### 4. Create an Assignment + +Assignments use a template repository as the starting point. The template +must already exist on the platform (e.g., `cs-department/hw1-starter`). + +```bash +curl -X POST http://localhost:8080/api/v1/classrooms/1/assignments \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Homework 1", + "description": "Implement a linked list in Python", + "template_repository": "cs-department/hw1-starter", + "max_team_size": 1, + "deadline": "2026-04-15T23:59:59Z" + }' +``` + +### 5. Students Accept Assignments + +When a student accepts an assignment, Class Forge creates a private repository +from the template and grants the student access: + +```bash +curl -X POST http://localhost:8080/api/v1/assignments/1/submissions \ + -H "Content-Type: application/json" \ + -d '{ + "student_id": 1 + }' +``` + +This creates a repository like `cs-department/hw1-alice-smith` and returns: + +```json +{ + "id": 1, + "assignment_id": 1, + "student_id": 1, + "repository_id": 999, + "repository_url": "https://forgejo.example.com/cs-department/hw1-alice-smith.git", + "status": "active", + "created_at": "2026-03-28T10:05:00Z" +} +``` + +## Team Assignments + +For group projects, set `max_team_size` greater than 1 when creating the +assignment. Students can then form teams: + +```bash +# Create a team +curl -X POST http://localhost:8080/api/v1/assignments/2/teams \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Team Alpha" + }' + +# Other students join +curl -X POST http://localhost:8080/api/v1/teams/1/join \ + -H "Content-Type: application/json" \ + -d '{ + "student_id": 2 + }' +``` + +## Next Steps + +- [Configuration Reference](configuration.md) - All config options and environment variables +- [API Reference](api-reference.md) - Complete endpoint documentation diff --git a/internal/api/errors.go b/internal/api/errors.go index 8aa092d..0dac885 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -32,10 +32,10 @@ const ( ErrBusinessTemplateNotFound = "BUSINESS_TEMPLATE_NOT_FOUND" // Integration Errors (INTEGRATION_*) - ErrIntegrationForgejoAPI = "INTEGRATION_FORGEJO_API_ERROR" - ErrIntegrationForgejoRateLimited = "INTEGRATION_FORGEJO_RATE_LIMITED" - ErrIntegrationForgejoUnavailable = "INTEGRATION_FORGEJO_UNAVAILABLE" - ErrIntegrationDatabase = "INTEGRATION_DATABASE_ERROR" + ErrIntegrationPlatformAPI = "INTEGRATION_PLATFORM_API_ERROR" + ErrIntegrationPlatformRateLimited = "INTEGRATION_PLATFORM_RATE_LIMITED" + ErrIntegrationPlatformUnavailable = "INTEGRATION_PLATFORM_UNAVAILABLE" + ErrIntegrationDatabase = "INTEGRATION_DATABASE_ERROR" // System Errors (SYSTEM_*) ErrSystemInternal = "SYSTEM_INTERNAL_ERROR" @@ -75,10 +75,10 @@ var ErrorMessages = map[string]string{ ErrBusinessTemplateNotFound: "Assignment template repository not found", // Integration Errors - ErrIntegrationForgejoAPI: "Forgejo API error", - ErrIntegrationForgejoRateLimited: "Forgejo API rate limit exceeded", - ErrIntegrationForgejoUnavailable: "Forgejo service unavailable", - ErrIntegrationDatabase: "Database operation failed", + ErrIntegrationPlatformAPI: "Git platform API error", + ErrIntegrationPlatformRateLimited: "Git platform API rate limit exceeded", + ErrIntegrationPlatformUnavailable: "Git platform service unavailable", + ErrIntegrationDatabase: "Database operation failed", // System Errors ErrSystemInternal: "Internal server error", diff --git a/internal/api/response.go b/internal/api/response.go index 00a42e0..2eba6cf 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -106,6 +106,6 @@ func getRequestID(c *gin.Context) string { func getDocumentationURL(errorCode string) string { // TODO: Return documentation URLs for error codes - baseURL := "https://docs.forgejo-classroom.org/errors/" + baseURL := "https://docs.class-forge.org/errors/" return baseURL + errorCode } diff --git a/internal/api/router.go b/internal/api/router.go index be1bbfb..009214b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,12 +6,12 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "code.forgejo.org/forgejo/classroom/internal/api/v1" - "code.forgejo.org/forgejo/classroom/internal/config" + v1 "code.forgejo.org/forgejo/classroom/internal/api/v1" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// NewRouter creates and configures the main API router -func NewRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine { +// NewRouter creates and configures the main API router. +func NewRouter(services *service.Services, logger *zap.Logger) *gin.Engine { router := gin.New() // Middleware @@ -25,8 +25,8 @@ func NewRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine { // API version info router.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ - "service": "forgejo-classroom", - "version": "dev", // TODO: Get from build info + "service": "class-forge", + "version": "dev", "api_versions": []string{ "v1", }, @@ -36,15 +36,11 @@ func NewRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine { // API v1 routes v1Group := router.Group("/api/v1") { - // TODO: Add authentication middleware - // v1Group.Use(authMiddleware()) - - // Register v1 handlers - v1.RegisterClassroomRoutes(v1Group, logger) - v1.RegisterAssignmentRoutes(v1Group, logger) - v1.RegisterRosterRoutes(v1Group, logger) - v1.RegisterSubmissionRoutes(v1Group, logger) - v1.RegisterTeamRoutes(v1Group, logger) + v1.RegisterClassroomRoutes(v1Group, services.Classroom, logger) + v1.RegisterAssignmentRoutes(v1Group, services.Assignment, services.Submission, logger) + v1.RegisterRosterRoutes(v1Group, services.Roster, logger) + v1.RegisterSubmissionRoutes(v1Group, services.Submission, logger) + v1.RegisterTeamRoutes(v1Group, services.Team, logger) } return router @@ -53,7 +49,7 @@ func NewRouter(cfg *config.Config, logger *zap.Logger) *gin.Engine { func healthCheck(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", - "service": "forgejo-classroom", + "service": "class-forge", }) } diff --git a/internal/api/v1/assignment.go b/internal/api/v1/assignment.go index e0f19a9..af71dc4 100644 --- a/internal/api/v1/assignment.go +++ b/internal/api/v1/assignment.go @@ -6,26 +6,26 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "code.forgejo.org/forgejo/classroom/internal/model" "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// AssignmentHandler handles assignment-related API endpoints +// AssignmentHandler handles assignment-related API endpoints. type AssignmentHandler struct { - logger *zap.Logger - // TODO: Add service dependencies - // service *service.AssignmentService + service *service.AssignmentService + submission *service.SubmissionService + logger *zap.Logger } -// NewAssignmentHandler creates a new assignment handler -func NewAssignmentHandler(logger *zap.Logger) *AssignmentHandler { - return &AssignmentHandler{ - logger: logger, - } +// NewAssignmentHandler creates a new assignment handler. +func NewAssignmentHandler(svc *service.AssignmentService, sub *service.SubmissionService, logger *zap.Logger) *AssignmentHandler { + return &AssignmentHandler{service: svc, submission: sub, logger: logger} } -// RegisterAssignmentRoutes registers assignment routes with the router group -func RegisterAssignmentRoutes(rg *gin.RouterGroup, logger *zap.Logger) { - handler := NewAssignmentHandler(logger) +// RegisterAssignmentRoutes registers assignment routes with the router group. +func RegisterAssignmentRoutes(rg *gin.RouterGroup, svc *service.AssignmentService, sub *service.SubmissionService, logger *zap.Logger) { + handler := NewAssignmentHandler(svc, sub, logger) assignments := rg.Group("/assignments") { @@ -41,120 +41,127 @@ func RegisterAssignmentRoutes(rg *gin.RouterGroup, logger *zap.Logger) { // CreateAssignment handles POST /api/v1/assignments func (h *AssignmentHandler) CreateAssignment(c *gin.Context) { - h.logger.Info("Creating assignment", zap.String("request_id", c.GetString("request_id"))) + var req model.CreateAssignmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } - // TODO: Implement assignment creation - // 1. Parse and validate request body (classroom_id, template_repo, deadline, etc.) - // 2. Validate template repository exists - // 3. Call service layer to create assignment - // 4. Return created assignment + assignment, err := h.service.Create(c.Request.Context(), &req) + if err != nil { + handleServiceError(c, err) + return + } - response.RespondWithData(c, http.StatusCreated, gin.H{ - "message": "Assignment creation not yet implemented", - "todo": "Parse request, validate template repo, call service layer", - }) + response.RespondWithData(c, http.StatusCreated, assignment) } // ListAssignments handles GET /api/v1/assignments func (h *AssignmentHandler) ListAssignments(c *gin.Context) { - h.logger.Info("Listing assignments", zap.String("request_id", c.GetString("request_id"))) + var req model.AssignmentListRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid query parameters", nil) + return + } - // TODO: Implement assignment listing - // 1. Parse query parameters (classroom_id, status, pagination) - // 2. Call service layer to get assignments - // 3. Return paginated list + result, err := h.service.List(c.Request.Context(), &req) + if err != nil { + handleServiceError(c, err) + return + } - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment listing not yet implemented", - "todo": "Parse query params, call service layer, return paginated results", + response.RespondWithSuccess(c, http.StatusOK, result.Assignments, &response.MetaInfo{ + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + TotalCount: result.Total, }) } // GetAssignment handles GET /api/v1/assignments/:id func (h *AssignmentHandler) GetAssignment(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Getting assignment", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement assignment retrieval - // 1. Validate ID parameter - // 2. Call service layer to get assignment - // 3. Return assignment details - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment retrieval not yet implemented", - "id": id, - "todo": "Validate ID, call service layer, return assignment", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + assignment, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, assignment) } // UpdateAssignment handles PUT /api/v1/assignments/:id func (h *AssignmentHandler) UpdateAssignment(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Updating assignment", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement assignment update - // 1. Validate ID parameter - // 2. Parse and validate request body - // 3. Call service layer to update assignment - // 4. Return updated assignment - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment update not yet implemented", - "id": id, - "todo": "Parse request, validate input, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + var req model.UpdateAssignmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } + + assignment, err := h.service.Update(c.Request.Context(), id, &req) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, assignment) } // DeleteAssignment handles DELETE /api/v1/assignments/:id func (h *AssignmentHandler) DeleteAssignment(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Deleting assignment", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement assignment deletion - // 1. Validate ID parameter - // 2. Check permissions and dependencies - // 3. Call service layer to delete assignment - // 4. Return success response - - response.RespondWithData(c, http.StatusNoContent, gin.H{ - "message": "Assignment deletion not yet implemented", - "id": id, - "todo": "Validate ID, check dependencies, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + if err := h.service.Delete(c.Request.Context(), id); err != nil { + handleServiceError(c, err) + return + } + + c.Status(http.StatusNoContent) } // GetAssignmentStats handles GET /api/v1/assignments/:id/stats func (h *AssignmentHandler) GetAssignmentStats(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Getting assignment stats", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement assignment statistics - // 1. Validate ID parameter - // 2. Call service layer to get statistics - // 3. Return stats (submissions, acceptance rate, etc.) - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment stats not yet implemented", - "id": id, - "todo": "Validate ID, call service layer, return stats", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + stats, err := h.service.GetStats(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, stats) } // AcceptAssignment handles POST /api/v1/assignments/:id/accept func (h *AssignmentHandler) AcceptAssignment(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Accepting assignment", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement assignment acceptance - // 1. Validate ID parameter - // 2. Check if user is in roster - // 3. Check if deadline hasn't passed - // 4. Create student repository from template - // 5. Return submission details - - response.RespondWithData(c, http.StatusCreated, gin.H{ - "message": "Assignment acceptance not yet implemented", - "id": id, - "todo": "Validate user, check deadline, create repo, return submission", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + // TODO: studentID should come from auth context + studentID := int64(1) + + submission, err := h.submission.AcceptAssignment(c.Request.Context(), id, studentID) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusCreated, submission) } diff --git a/internal/api/v1/classroom.go b/internal/api/v1/classroom.go index 16a808b..e106bf6 100644 --- a/internal/api/v1/classroom.go +++ b/internal/api/v1/classroom.go @@ -2,30 +2,30 @@ package v1 import ( "net/http" + "strconv" "github.com/gin-gonic/gin" "go.uber.org/zap" + "code.forgejo.org/forgejo/classroom/internal/model" "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// ClassroomHandler handles classroom-related API endpoints +// ClassroomHandler handles classroom-related API endpoints. type ClassroomHandler struct { - logger *zap.Logger - // TODO: Add service dependencies - // service *service.ClassroomService + service *service.ClassroomService + logger *zap.Logger } -// NewClassroomHandler creates a new classroom handler -func NewClassroomHandler(logger *zap.Logger) *ClassroomHandler { - return &ClassroomHandler{ - logger: logger, - } +// NewClassroomHandler creates a new classroom handler. +func NewClassroomHandler(svc *service.ClassroomService, logger *zap.Logger) *ClassroomHandler { + return &ClassroomHandler{service: svc, logger: logger} } -// RegisterClassroomRoutes registers classroom routes with the router group -func RegisterClassroomRoutes(rg *gin.RouterGroup, logger *zap.Logger) { - handler := NewClassroomHandler(logger) +// RegisterClassroomRoutes registers classroom routes with the router group. +func RegisterClassroomRoutes(rg *gin.RouterGroup, svc *service.ClassroomService, logger *zap.Logger) { + handler := NewClassroomHandler(svc, logger) classrooms := rg.Group("/classrooms") { @@ -40,101 +40,124 @@ func RegisterClassroomRoutes(rg *gin.RouterGroup, logger *zap.Logger) { // CreateClassroom handles POST /api/v1/classrooms func (h *ClassroomHandler) CreateClassroom(c *gin.Context) { - h.logger.Info("Creating classroom", zap.String("request_id", c.GetString("request_id"))) + var req model.CreateClassroomRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } - // TODO: Implement classroom creation - // 1. Parse and validate request body - // 2. Call service layer to create classroom - // 3. Return created classroom + // In a real system these come from auth middleware; placeholder for now. + instructorID := int64(1) + instructorLogin := "admin" - response.RespondWithData(c, http.StatusCreated, gin.H{ - "message": "Classroom creation not yet implemented", - "todo": "Parse request, validate input, call service layer", - }) + classroom, err := h.service.Create(c.Request.Context(), &req, instructorID, instructorLogin) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusCreated, classroom) } // ListClassrooms handles GET /api/v1/classrooms func (h *ClassroomHandler) ListClassrooms(c *gin.Context) { - h.logger.Info("Listing classrooms", zap.String("request_id", c.GetString("request_id"))) + var req model.ClassroomListRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid query parameters", nil) + return + } - // TODO: Implement classroom listing - // 1. Parse query parameters (filters, pagination) - // 2. Call service layer to get classrooms - // 3. Return paginated list + result, err := h.service.List(c.Request.Context(), &req) + if err != nil { + handleServiceError(c, err) + return + } - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Classroom listing not yet implemented", - "todo": "Parse query params, call service layer, return paginated results", + response.RespondWithSuccess(c, http.StatusOK, result.Classrooms, &response.MetaInfo{ + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + TotalCount: result.Total, }) } // GetClassroom handles GET /api/v1/classrooms/:id func (h *ClassroomHandler) GetClassroom(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Getting classroom", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement classroom retrieval - // 1. Validate ID parameter - // 2. Call service layer to get classroom - // 3. Return classroom details - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Classroom retrieval not yet implemented", - "id": id, - "todo": "Validate ID, call service layer, return classroom", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + classroom, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, classroom) } // UpdateClassroom handles PUT /api/v1/classrooms/:id func (h *ClassroomHandler) UpdateClassroom(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Updating classroom", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement classroom update - // 1. Validate ID parameter - // 2. Parse and validate request body - // 3. Call service layer to update classroom - // 4. Return updated classroom - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Classroom update not yet implemented", - "id": id, - "todo": "Parse request, validate input, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + var req model.UpdateClassroomRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } + + classroom, err := h.service.Update(c.Request.Context(), id, &req) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, classroom) } // DeleteClassroom handles DELETE /api/v1/classrooms/:id func (h *ClassroomHandler) DeleteClassroom(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Deleting classroom", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement classroom deletion - // 1. Validate ID parameter - // 2. Check permissions - // 3. Call service layer to delete classroom - // 4. Return success response - - response.RespondWithData(c, http.StatusNoContent, gin.H{ - "message": "Classroom deletion not yet implemented", - "id": id, - "todo": "Validate ID, check permissions, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + if err := h.service.Delete(c.Request.Context(), id); err != nil { + handleServiceError(c, err) + return + } + + c.Status(http.StatusNoContent) } // ArchiveClassroom handles POST /api/v1/classrooms/:id/archive func (h *ClassroomHandler) ArchiveClassroom(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Archiving classroom", zap.String("id", id), zap.String("request_id", c.GetString("request_id"))) - - // TODO: Implement classroom archiving - // 1. Validate ID parameter - // 2. Check permissions - // 3. Call service layer to archive classroom - // 4. Return archived classroom - - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Classroom archiving not yet implemented", - "id": id, - "todo": "Validate ID, check permissions, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + classroom, err := h.service.Archive(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, classroom) +} + +// --- helpers --- + +func parseID(c *gin.Context, param string) (int64, error) { + id, err := strconv.ParseInt(c.Param(param), 10, 64) + if err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", + param+" must be an integer", nil) + return 0, err + } + return id, nil } diff --git a/internal/api/v1/errors.go b/internal/api/v1/errors.go new file mode 100644 index 0000000..3281053 --- /dev/null +++ b/internal/api/v1/errors.go @@ -0,0 +1,61 @@ +package v1 + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/util" +) + +// handleServiceError inspects a service-layer error and responds with the +// appropriate HTTP status code and error body. +func handleServiceError(c *gin.Context, err error) { + if err == nil { + return + } + + // Validation errors from the util.Validator + if verrs, ok := err.(util.ValidationErrors); ok { + details := make(map[string]interface{}) + fields := make([]map[string]string, 0, len(verrs)) + for _, ve := range verrs { + fields = append(fields, map[string]string{ + "field": ve.Field, + "message": ve.Message, + "code": ve.Code, + }) + } + details["fields"] = fields + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Request validation failed", details) + return + } + + msg := err.Error() + + // Map error-code prefixes to HTTP status codes + switch { + case strings.HasPrefix(msg, "RESOURCE_NOT_FOUND"): + response.NotFound(c, "RESOURCE_NOT_FOUND", msg) + case strings.HasPrefix(msg, "RESOURCE_ALREADY_EXISTS"): + response.Conflict(c, "RESOURCE_ALREADY_EXISTS", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_DEADLINE_PASSED"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_DEADLINE_PASSED", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_ROSTER_NOT_FOUND"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_ROSTER_NOT_FOUND", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_TEAM_SIZE_EXCEEDED"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_TEAM_SIZE_EXCEEDED", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_TEMPLATE_NOT_FOUND"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_TEMPLATE_NOT_FOUND", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_ALREADY_ACCEPTED"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_ALREADY_ACCEPTED", msg, nil) + case strings.HasPrefix(msg, "BUSINESS_OPERATION_NOT_ALLOWED"): + response.RespondWithError(c, http.StatusUnprocessableEntity, "BUSINESS_OPERATION_NOT_ALLOWED", msg, nil) + case strings.HasPrefix(msg, "INTEGRATION_PLATFORM"): + response.InternalServerError(c, "INTEGRATION_PLATFORM_API_ERROR", msg) + default: + response.InternalServerError(c, "SYSTEM_INTERNAL_ERROR", "An internal error occurred") + } +} diff --git a/internal/api/v1/roster.go b/internal/api/v1/roster.go index 7277a06..b378de1 100644 --- a/internal/api/v1/roster.go +++ b/internal/api/v1/roster.go @@ -6,81 +6,117 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "code.forgejo.org/forgejo/classroom/internal/model" "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// RosterHandler handles roster-related API endpoints +// RosterHandler handles roster-related API endpoints. type RosterHandler struct { - logger *zap.Logger - // TODO: Add service dependencies - // service *service.RosterService + service *service.RosterService + logger *zap.Logger } -// NewRosterHandler creates a new roster handler -func NewRosterHandler(logger *zap.Logger) *RosterHandler { - return &RosterHandler{ - logger: logger, - } +// NewRosterHandler creates a new roster handler. +func NewRosterHandler(svc *service.RosterService, logger *zap.Logger) *RosterHandler { + return &RosterHandler{service: svc, logger: logger} } -// RegisterRosterRoutes registers roster routes with the router group -func RegisterRosterRoutes(rg *gin.RouterGroup, logger *zap.Logger) { - handler := NewRosterHandler(logger) +// RegisterRosterRoutes registers roster routes with the router group. +func RegisterRosterRoutes(rg *gin.RouterGroup, svc *service.RosterService, logger *zap.Logger) { + handler := NewRosterHandler(svc, logger) rosters := rg.Group("/classrooms/:classroom_id/roster") { rosters.POST("/students", handler.AddStudent) rosters.GET("/students", handler.ListStudents) rosters.POST("/students/:student_id/link", handler.LinkStudent) - rosters.POST("/import", handler.ImportRoster) + rosters.DELETE("/students/:student_id", handler.DeleteStudent) } } // AddStudent handles POST /api/v1/classrooms/:classroom_id/roster/students func (h *RosterHandler) AddStudent(c *gin.Context) { - classroomID := c.Param("classroom_id") - h.logger.Info("Adding student to roster", zap.String("classroom_id", classroomID)) + classroomID, err := parseID(c, "classroom_id") + if err != nil { + return + } - // TODO: Implement student addition - response.RespondWithData(c, http.StatusCreated, gin.H{ - "message": "Student addition not yet implemented", - "todo": "Parse request, validate student data, call service layer", - }) + var req model.AddStudentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } + + entry, err := h.service.AddStudent(c.Request.Context(), classroomID, &req) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusCreated, entry) } // ListStudents handles GET /api/v1/classrooms/:classroom_id/roster/students func (h *RosterHandler) ListStudents(c *gin.Context) { - classroomID := c.Param("classroom_id") - h.logger.Info("Listing roster students", zap.String("classroom_id", classroomID)) + classroomID, err := parseID(c, "classroom_id") + if err != nil { + return + } + + var req model.RosterListRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid query parameters", nil) + return + } + + result, err := h.service.List(c.Request.Context(), classroomID, &req) + if err != nil { + handleServiceError(c, err) + return + } - // TODO: Implement student listing - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Student listing not yet implemented", - "todo": "Parse filters, call service layer, return paginated results", + response.RespondWithSuccess(c, http.StatusOK, result.Students, &response.MetaInfo{ + Page: result.Page, + PerPage: result.PerPage, + TotalPages: result.TotalPages, + TotalCount: result.Total, }) } // LinkStudent handles POST /api/v1/classrooms/:classroom_id/roster/students/:student_id/link func (h *RosterHandler) LinkStudent(c *gin.Context) { - classroomID := c.Param("classroom_id") - studentID := c.Param("student_id") - h.logger.Info("Linking student account", zap.String("classroom_id", classroomID), zap.String("student_id", studentID)) - - // TODO: Implement account linking - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Account linking not yet implemented", - "todo": "Parse forgejo username, validate, call service layer", - }) + studentID, err := parseID(c, "student_id") + if err != nil { + return + } + + var req model.LinkStudentRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } + + entry, err := h.service.LinkStudent(c.Request.Context(), studentID, &req) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, entry) } -// ImportRoster handles POST /api/v1/classrooms/:classroom_id/roster/import -func (h *RosterHandler) ImportRoster(c *gin.Context) { - classroomID := c.Param("classroom_id") - h.logger.Info("Importing roster", zap.String("classroom_id", classroomID)) +// DeleteStudent handles DELETE /api/v1/classrooms/:classroom_id/roster/students/:student_id +func (h *RosterHandler) DeleteStudent(c *gin.Context) { + studentID, err := parseID(c, "student_id") + if err != nil { + return + } - // TODO: Implement roster import - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Roster import not yet implemented", - "todo": "Parse CSV file, validate data, bulk import students", - }) + if err := h.service.Delete(c.Request.Context(), studentID); err != nil { + handleServiceError(c, err) + return + } + + c.Status(http.StatusNoContent) } diff --git a/internal/api/v1/submission.go b/internal/api/v1/submission.go index 0fc0142..49f097b 100644 --- a/internal/api/v1/submission.go +++ b/internal/api/v1/submission.go @@ -1,104 +1,126 @@ package v1 import ( + "fmt" "net/http" "github.com/gin-gonic/gin" "go.uber.org/zap" + "code.forgejo.org/forgejo/classroom/internal/repository" "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// SubmissionHandler handles submission-related API endpoints +// SubmissionHandler handles submission-related API endpoints. type SubmissionHandler struct { - logger *zap.Logger - // TODO: Add service dependencies - // service *service.SubmissionService + service *service.SubmissionService + logger *zap.Logger } -// NewSubmissionHandler creates a new submission handler -func NewSubmissionHandler(logger *zap.Logger) *SubmissionHandler { - return &SubmissionHandler{ - logger: logger, - } +// NewSubmissionHandler creates a new submission handler. +func NewSubmissionHandler(svc *service.SubmissionService, logger *zap.Logger) *SubmissionHandler { + return &SubmissionHandler{service: svc, logger: logger} } -// RegisterSubmissionRoutes registers submission routes with the router group -func RegisterSubmissionRoutes(rg *gin.RouterGroup, logger *zap.Logger) { - handler := NewSubmissionHandler(logger) +// RegisterSubmissionRoutes registers submission routes with the router group. +func RegisterSubmissionRoutes(rg *gin.RouterGroup, svc *service.SubmissionService, logger *zap.Logger) { + handler := NewSubmissionHandler(svc, logger) submissions := rg.Group("/submissions") { submissions.GET("", handler.ListSubmissions) submissions.GET("/:id", handler.GetSubmission) - submissions.GET("/:id/download", handler.DownloadSubmission) } - // Assignment-specific submissions - assignmentSubmissions := rg.Group("/assignments/:assignment_id/submissions") - { - assignmentSubmissions.GET("", handler.ListAssignmentSubmissions) - assignmentSubmissions.GET("/download", handler.DownloadAllSubmissions) - } + // Nested under assignments + rg.GET("/assignments/:assignment_id/submissions", handler.ListByAssignment) } // ListSubmissions handles GET /api/v1/submissions func (h *SubmissionHandler) ListSubmissions(c *gin.Context) { - h.logger.Info("Listing submissions") + page := intParam(c, "page", 1) + perPage := intParam(c, "per_page", 20) - // TODO: Implement submission listing - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Submission listing not yet implemented", - "todo": "Parse filters, call service layer, return paginated results", + filter := repository.SubmissionFilter{ + Status: c.Query("status"), + } + + submissions, total, err := h.service.List(c.Request.Context(), filter, repository.Pagination{Page: page, PerPage: perPage}) + if err != nil { + handleServiceError(c, err) + return + } + + pg := repository.Pagination{Page: page, PerPage: perPage} + response.RespondWithSuccess(c, http.StatusOK, submissions, &response.MetaInfo{ + Page: page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + TotalCount: total, }) } // GetSubmission handles GET /api/v1/submissions/:id func (h *SubmissionHandler) GetSubmission(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Getting submission", zap.String("id", id)) - - // TODO: Implement submission retrieval - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Submission retrieval not yet implemented", - "id": id, - "todo": "Validate ID, call service layer, return submission details", - }) -} + id, err := parseID(c, "id") + if err != nil { + return + } -// DownloadSubmission handles GET /api/v1/submissions/:id/download -func (h *SubmissionHandler) DownloadSubmission(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Downloading submission", zap.String("id", id)) + submission, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } - // TODO: Implement submission download - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Submission download not yet implemented", - "id": id, - "todo": "Generate archive, stream file response", - }) + response.RespondWithData(c, http.StatusOK, submission) } -// ListAssignmentSubmissions handles GET /api/v1/assignments/:assignment_id/submissions -func (h *SubmissionHandler) ListAssignmentSubmissions(c *gin.Context) { - assignmentID := c.Param("assignment_id") - h.logger.Info("Listing assignment submissions", zap.String("assignment_id", assignmentID)) +// ListByAssignment handles GET /api/v1/assignments/:assignment_id/submissions +func (h *SubmissionHandler) ListByAssignment(c *gin.Context) { + assignmentID, err := parseID(c, "assignment_id") + if err != nil { + return + } + + page := intParam(c, "page", 1) + perPage := intParam(c, "per_page", 20) + pg := repository.Pagination{Page: page, PerPage: perPage} + + submissions, total, err := h.service.ListByAssignment(c.Request.Context(), assignmentID, pg) + if err != nil { + handleServiceError(c, err) + return + } - // TODO: Implement assignment submissions listing - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment submissions listing not yet implemented", - "todo": "Parse filters, call service layer, return submissions", + response.RespondWithSuccess(c, http.StatusOK, submissions, &response.MetaInfo{ + Page: page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + TotalCount: total, }) } -// DownloadAllSubmissions handles GET /api/v1/assignments/:assignment_id/submissions/download -func (h *SubmissionHandler) DownloadAllSubmissions(c *gin.Context) { - assignmentID := c.Param("assignment_id") - h.logger.Info("Downloading all assignment submissions", zap.String("assignment_id", assignmentID)) +func intParam(c *gin.Context, name string, defaultVal int) int { + v := c.Query(name) + if v == "" { + return defaultVal + } + n, err := parseInt64(v) + if err != nil { + return defaultVal + } + return int(n) +} - // TODO: Implement bulk submission download - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Bulk submission download not yet implemented", - "todo": "Generate bulk archive, stream response", - }) +func parseInt64(s string) (int64, error) { + var n int64 + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("not a number") + } + n = n*10 + int64(c-'0') + } + return n, nil } diff --git a/internal/api/v1/team.go b/internal/api/v1/team.go index b3288f1..7c54418 100644 --- a/internal/api/v1/team.go +++ b/internal/api/v1/team.go @@ -6,26 +6,26 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/repository" "code.forgejo.org/forgejo/classroom/internal/response" + "code.forgejo.org/forgejo/classroom/internal/service" ) -// TeamHandler handles team-related API endpoints +// TeamHandler handles team-related API endpoints. type TeamHandler struct { - logger *zap.Logger - // TODO: Add service dependencies - // service *service.TeamService + service *service.TeamService + logger *zap.Logger } -// NewTeamHandler creates a new team handler -func NewTeamHandler(logger *zap.Logger) *TeamHandler { - return &TeamHandler{ - logger: logger, - } +// NewTeamHandler creates a new team handler. +func NewTeamHandler(svc *service.TeamService, logger *zap.Logger) *TeamHandler { + return &TeamHandler{service: svc, logger: logger} } -// RegisterTeamRoutes registers team routes with the router group -func RegisterTeamRoutes(rg *gin.RouterGroup, logger *zap.Logger) { - handler := NewTeamHandler(logger) +// RegisterTeamRoutes registers team routes with the router group. +func RegisterTeamRoutes(rg *gin.RouterGroup, svc *service.TeamService, logger *zap.Logger) { + handler := NewTeamHandler(svc, logger) teams := rg.Group("/teams") { @@ -33,73 +33,121 @@ func RegisterTeamRoutes(rg *gin.RouterGroup, logger *zap.Logger) { teams.GET("/:id", handler.GetTeam) teams.POST("/:id/join", handler.JoinTeam) teams.POST("/:id/leave", handler.LeaveTeam) + teams.DELETE("/:id", handler.DeleteTeam) } - // Assignment-specific teams - assignmentTeams := rg.Group("/assignments/:assignment_id/teams") - { - assignmentTeams.GET("", handler.ListAssignmentTeams) - } + // Nested under assignments + rg.GET("/assignments/:assignment_id/teams", handler.ListByAssignment) } // CreateTeam handles POST /api/v1/teams func (h *TeamHandler) CreateTeam(c *gin.Context) { - h.logger.Info("Creating team") + var req model.CreateTeamRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "VALIDATION_INVALID_INPUT", "Invalid request body", nil) + return + } - // TODO: Implement team creation - response.RespondWithData(c, http.StatusCreated, gin.H{ - "message": "Team creation not yet implemented", - "todo": "Parse request, validate assignment, call service layer", - }) + // TODO: leaderStudentID should come from auth context + leaderStudentID := int64(1) + + team, err := h.service.Create(c.Request.Context(), &req, leaderStudentID) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusCreated, team) } // GetTeam handles GET /api/v1/teams/:id func (h *TeamHandler) GetTeam(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Getting team", zap.String("id", id)) - - // TODO: Implement team retrieval - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Team retrieval not yet implemented", - "id": id, - "todo": "Validate ID, call service layer, return team details", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + team, err := h.service.GetByID(c.Request.Context(), id) + if err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, team) } // JoinTeam handles POST /api/v1/teams/:id/join func (h *TeamHandler) JoinTeam(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Joining team", zap.String("id", id)) - - // TODO: Implement team joining - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Team joining not yet implemented", - "id": id, - "todo": "Validate team size, check eligibility, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + // TODO: studentID should come from auth context + studentID := int64(1) + + if err := h.service.JoinTeam(c.Request.Context(), id, studentID); err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, gin.H{"message": "joined team successfully"}) } // LeaveTeam handles POST /api/v1/teams/:id/leave func (h *TeamHandler) LeaveTeam(c *gin.Context) { - id := c.Param("id") - h.logger.Info("Leaving team", zap.String("id", id)) - - // TODO: Implement team leaving - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Team leaving not yet implemented", - "id": id, - "todo": "Validate membership, handle leadership transfer, call service layer", - }) + id, err := parseID(c, "id") + if err != nil { + return + } + + // TODO: studentID should come from auth context + studentID := int64(1) + + if err := h.service.LeaveTeam(c.Request.Context(), id, studentID); err != nil { + handleServiceError(c, err) + return + } + + response.RespondWithData(c, http.StatusOK, gin.H{"message": "left team successfully"}) } -// ListAssignmentTeams handles GET /api/v1/assignments/:assignment_id/teams -func (h *TeamHandler) ListAssignmentTeams(c *gin.Context) { - assignmentID := c.Param("assignment_id") - h.logger.Info("Listing assignment teams", zap.String("assignment_id", assignmentID)) +// DeleteTeam handles DELETE /api/v1/teams/:id +func (h *TeamHandler) DeleteTeam(c *gin.Context) { + id, err := parseID(c, "id") + if err != nil { + return + } + + if err := h.service.Delete(c.Request.Context(), id); err != nil { + handleServiceError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + +// ListByAssignment handles GET /api/v1/assignments/:assignment_id/teams +func (h *TeamHandler) ListByAssignment(c *gin.Context) { + assignmentID, err := parseID(c, "assignment_id") + if err != nil { + return + } + + page := intParam(c, "page", 1) + perPage := intParam(c, "per_page", 20) + pg := repository.Pagination{Page: page, PerPage: perPage} + + teams, total, err := h.service.ListByAssignment(c.Request.Context(), assignmentID, pg) + if err != nil { + handleServiceError(c, err) + return + } - // TODO: Implement assignment teams listing - response.RespondWithData(c, http.StatusOK, gin.H{ - "message": "Assignment teams listing not yet implemented", - "todo": "Parse filters, call service layer, return teams", + response.RespondWithSuccess(c, http.StatusOK, teams, &response.MetaInfo{ + Page: page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + TotalCount: total, }) } diff --git a/internal/config/config.go b/internal/config/config.go index 52c716b..a3f3a98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,8 @@ type Config struct { Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` Forgejo ForgejoConfig `mapstructure:"forgejo"` + GitLab GitLabConfig `mapstructure:"gitlab"` + Platform PlatformConfig `mapstructure:"platform"` Cache CacheConfig `mapstructure:"cache"` Queue QueueConfig `mapstructure:"queue"` Auth AuthConfig `mapstructure:"auth"` @@ -52,6 +54,12 @@ type RedisConfig struct { Timeout time.Duration `mapstructure:"timeout"` } +// PlatformConfig selects which git hosting provider to use. +// Valid values: "forgejo", "gitlab". +type PlatformConfig struct { + Type string `mapstructure:"type"` +} + // ForgejoConfig holds Forgejo integration configuration type ForgejoConfig struct { BaseURL string `mapstructure:"base_url"` @@ -60,6 +68,14 @@ type ForgejoConfig struct { RateLimit RateLimitConfig `mapstructure:"rate_limit"` } +// GitLabConfig holds GitLab integration configuration +type GitLabConfig struct { + BaseURL string `mapstructure:"base_url"` + Token string `mapstructure:"token"` + Timeout time.Duration `mapstructure:"timeout"` + RateLimit RateLimitConfig `mapstructure:"rate_limit"` +} + // RateLimitConfig holds rate limiting configuration type RateLimitConfig struct { RequestsPerMinute int `mapstructure:"requests_per_minute"` @@ -167,6 +183,10 @@ func setDefaults(config *Config) { config.Redis.Timeout = 5 * time.Second } + if config.Platform.Type == "" { + config.Platform.Type = "forgejo" + } + if config.Forgejo.Timeout == 0 { config.Forgejo.Timeout = 30 * time.Second } @@ -177,6 +197,16 @@ func setDefaults(config *Config) { config.Forgejo.RateLimit.BurstSize = 10 } + if config.GitLab.Timeout == 0 { + config.GitLab.Timeout = 30 * time.Second + } + if config.GitLab.RateLimit.RequestsPerMinute == 0 { + config.GitLab.RateLimit.RequestsPerMinute = 60 + } + if config.GitLab.RateLimit.BurstSize == 0 { + config.GitLab.RateLimit.BurstSize = 10 + } + if config.Cache.DefaultTTL == 0 { config.Cache.DefaultTTL = 15 * time.Minute } @@ -226,12 +256,29 @@ func validate(config *Config) error { if config.Database.User == "" { return fmt.Errorf("database user is required") } - if config.Forgejo.BaseURL == "" { - return fmt.Errorf("forgejo base URL is required") - } - if config.Forgejo.Token == "" { - return fmt.Errorf("forgejo token is required") + // Validate platform selection and corresponding config + validPlatforms := map[string]bool{"forgejo": true, "gitlab": true} + if !validPlatforms[config.Platform.Type] { + return fmt.Errorf("invalid platform type: %s (must be forgejo or gitlab)", config.Platform.Type) + } + + switch config.Platform.Type { + case "forgejo": + if config.Forgejo.BaseURL == "" { + return fmt.Errorf("forgejo base URL is required when platform type is forgejo") + } + if config.Forgejo.Token == "" { + return fmt.Errorf("forgejo token is required when platform type is forgejo") + } + case "gitlab": + if config.GitLab.BaseURL == "" { + return fmt.Errorf("gitlab base URL is required when platform type is gitlab") + } + if config.GitLab.Token == "" { + return fmt.Errorf("gitlab token is required when platform type is gitlab") + } } + if config.Auth.JWTSecret == "" { return fmt.Errorf("JWT secret is required") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..dccba7b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func validBaseConfig() *Config { + return &Config{ + Database: DatabaseConfig{ + Name: "testdb", + User: "testuser", + }, + Auth: AuthConfig{ + JWTSecret: "test-secret", + }, + } +} + +func TestSetDefaults_PlatformType(t *testing.T) { + cfg := &Config{} + setDefaults(cfg) + assert.Equal(t, "forgejo", cfg.Platform.Type) +} + +func TestSetDefaults_GitLabTimeout(t *testing.T) { + cfg := &Config{} + setDefaults(cfg) + assert.NotZero(t, cfg.GitLab.Timeout) + assert.NotZero(t, cfg.GitLab.RateLimit.RequestsPerMinute) + assert.NotZero(t, cfg.GitLab.RateLimit.BurstSize) +} + +func TestValidate_ForgejoRequiresBaseURL(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "forgejo" + cfg.Forgejo.Token = "some-token" + // BaseURL is empty + + err := validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "forgejo base URL is required") +} + +func TestValidate_ForgejoRequiresToken(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "forgejo" + cfg.Forgejo.BaseURL = "https://forgejo.example.com" + // Token is empty + + err := validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "forgejo token is required") +} + +func TestValidate_ForgejoSuccess(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "forgejo" + cfg.Forgejo.BaseURL = "https://forgejo.example.com" + cfg.Forgejo.Token = "some-token" + setDefaults(cfg) + + err := validate(cfg) + assert.NoError(t, err) +} + +func TestValidate_GitLabRequiresBaseURL(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "gitlab" + cfg.GitLab.Token = "some-token" + // BaseURL is empty + + err := validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "gitlab base URL is required") +} + +func TestValidate_GitLabRequiresToken(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "gitlab" + cfg.GitLab.BaseURL = "https://gitlab.example.com" + // Token is empty + + err := validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "gitlab token is required") +} + +func TestValidate_GitLabSuccess(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "gitlab" + cfg.GitLab.BaseURL = "https://gitlab.example.com" + cfg.GitLab.Token = "some-token" + setDefaults(cfg) + + err := validate(cfg) + assert.NoError(t, err) +} + +func TestValidate_InvalidPlatformType(t *testing.T) { + cfg := validBaseConfig() + cfg.Platform.Type = "github" + setDefaults(cfg) + + err := validate(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid platform type") +} diff --git a/internal/model/roster.go b/internal/model/roster.go index 4eb447b..c4ec944 100644 --- a/internal/model/roster.go +++ b/internal/model/roster.go @@ -6,17 +6,17 @@ import ( // RosterEntry represents a student in a classroom roster type RosterEntry struct { - ID int64 `json:"id" db:"id"` - ClassroomID int64 `json:"classroom_id" db:"classroom_id"` - StudentName string `json:"student_name" db:"student_name"` - StudentEmail string `json:"student_email" db:"student_email"` - StudentID string `json:"student_id" db:"student_id"` - ForgejoUsername *string `json:"forgejo_username,omitempty" db:"forgejo_username"` - ForgejoUserID *int64 `json:"forgejo_user_id,omitempty" db:"forgejo_user_id"` - Role string `json:"role" db:"role"` // student, assistant, instructor - LinkedAt *time.Time `json:"linked_at,omitempty" db:"linked_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID int64 `json:"id" db:"id"` + ClassroomID int64 `json:"classroom_id" db:"classroom_id"` + StudentName string `json:"student_name" db:"student_name"` + StudentEmail string `json:"student_email" db:"student_email"` + StudentID string `json:"student_id" db:"student_id"` + PlatformUsername *string `json:"platform_username,omitempty" db:"platform_username"` + PlatformUserID *int64 `json:"platform_user_id,omitempty" db:"platform_user_id"` + Role string `json:"role" db:"role"` // student, assistant, instructor + LinkedAt *time.Time `json:"linked_at,omitempty" db:"linked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // AddStudentRequest represents the request to add a student to roster @@ -27,9 +27,10 @@ type AddStudentRequest struct { Role string `json:"role"` // defaults to "student" } -// LinkStudentRequest represents the request to link a student account +// LinkStudentRequest represents the request to link a student's +// git platform account. type LinkStudentRequest struct { - ForgejoUsername string `json:"forgejo_username" binding:"required"` + PlatformUsername string `json:"platform_username" binding:"required"` } // RosterListRequest represents the request to list roster entries @@ -49,7 +50,7 @@ type RosterListResponse struct { TotalPages int `json:"total_pages"` } -// IsLinked returns true if the student has a linked Forgejo account +// IsLinked returns true if the student has a linked platform account. func (r *RosterEntry) IsLinked() bool { - return r.ForgejoUsername != nil && *r.ForgejoUsername != "" + return r.PlatformUsername != nil && *r.PlatformUsername != "" } diff --git a/internal/model/roster_test.go b/internal/model/roster_test.go new file mode 100644 index 0000000..8f9c1a4 --- /dev/null +++ b/internal/model/roster_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRosterEntry_IsLinked(t *testing.T) { + tests := []struct { + name string + entry RosterEntry + expected bool + }{ + { + name: "nil username", + entry: RosterEntry{}, + expected: false, + }, + { + name: "empty username", + entry: RosterEntry{PlatformUsername: strPtr("")}, + expected: false, + }, + { + name: "linked with username", + entry: RosterEntry{PlatformUsername: strPtr("student1")}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.entry.IsLinked()) + }) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/model/team.go b/internal/model/team.go index 39b882c..d729a53 100644 --- a/internal/model/team.go +++ b/internal/model/team.go @@ -65,8 +65,8 @@ type TeamWithMembers struct { // TeamMemberInfo represents detailed information about a team member type TeamMemberInfo struct { TeamMember - StudentName string `json:"student_name"` - ForgejoUsername string `json:"forgejo_username"` + StudentName string `json:"student_name"` + PlatformUsername string `json:"platform_username"` } // CanAddMember returns true if the team can accept more members diff --git a/internal/platform/errors.go b/internal/platform/errors.go new file mode 100644 index 0000000..00e8f43 --- /dev/null +++ b/internal/platform/errors.go @@ -0,0 +1,26 @@ +package platform + +import "errors" + +// Sentinel errors returned by platform providers. Callers should use +// errors.Is to check for these. +var ( + // ErrNotFound indicates the requested resource does not exist. + ErrNotFound = errors.New("platform: resource not found") + + // ErrAlreadyExists indicates a resource with the same identifier + // already exists. + ErrAlreadyExists = errors.New("platform: resource already exists") + + // ErrForbidden indicates the authenticated user lacks permission. + ErrForbidden = errors.New("platform: forbidden") + + // ErrUnauthorized indicates missing or invalid credentials. + ErrUnauthorized = errors.New("platform: unauthorized") + + // ErrRateLimited indicates the platform's rate limit has been hit. + ErrRateLimited = errors.New("platform: rate limited") + + // ErrUnavailable indicates the platform is temporarily unreachable. + ErrUnavailable = errors.New("platform: service unavailable") +) diff --git a/internal/platform/errors_test.go b/internal/platform/errors_test.go new file mode 100644 index 0000000..abb8e16 --- /dev/null +++ b/internal/platform/errors_test.go @@ -0,0 +1,50 @@ +package platform + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSentinelErrors(t *testing.T) { + tests := []struct { + name string + err error + }{ + {"ErrNotFound", ErrNotFound}, + {"ErrAlreadyExists", ErrAlreadyExists}, + {"ErrForbidden", ErrForbidden}, + {"ErrUnauthorized", ErrUnauthorized}, + {"ErrRateLimited", ErrRateLimited}, + {"ErrUnavailable", ErrUnavailable}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NotNil(t, tt.err) + assert.NotEmpty(t, tt.err.Error()) + }) + } +} + +func TestSentinelErrorsAreDistinct(t *testing.T) { + errs := []error{ + ErrNotFound, ErrAlreadyExists, ErrForbidden, + ErrUnauthorized, ErrRateLimited, ErrUnavailable, + } + + for i := 0; i < len(errs); i++ { + for j := i + 1; j < len(errs); j++ { + assert.False(t, errors.Is(errs[i], errs[j]), + "%v should not match %v", errs[i], errs[j]) + } + } +} + +func TestWrappedSentinelErrors(t *testing.T) { + wrapped := fmt.Errorf("something went wrong: %w", ErrNotFound) + assert.True(t, errors.Is(wrapped, ErrNotFound)) + assert.False(t, errors.Is(wrapped, ErrForbidden)) +} diff --git a/internal/platform/forgejo/forgejo.go b/internal/platform/forgejo/forgejo.go new file mode 100644 index 0000000..bd120fd --- /dev/null +++ b/internal/platform/forgejo/forgejo.go @@ -0,0 +1,167 @@ +// Package forgejo implements the platform.Provider interface for Forgejo +// (and Gitea-compatible) instances. +package forgejo + +import ( + "context" + "fmt" + "net/http" + "time" + + "code.forgejo.org/forgejo/classroom/internal/platform" +) + +// Provider implements platform.Provider for Forgejo instances. +// It also implements platform.CollaboratorProvider since Forgejo supports +// direct repository collaborators. +type Provider struct { + baseURL string + token string + httpClient *http.Client +} + +// Ensure compile-time interface compliance. +var ( + _ platform.Provider = (*Provider)(nil) + _ platform.CollaboratorProvider = (*Provider)(nil) +) + +// New creates a new Forgejo provider from the given platform config. +func New(cfg platform.ProviderConfig) (platform.Provider, error) { + if cfg.BaseURL == "" { + return nil, fmt.Errorf("forgejo: base URL is required") + } + if cfg.Token == "" { + return nil, fmt.Errorf("forgejo: access token is required") + } + + timeout := 30 * time.Second + if cfg.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.TimeoutSeconds) * time.Second + } + + return &Provider{ + baseURL: cfg.BaseURL, + token: cfg.Token, + httpClient: &http.Client{ + Timeout: timeout, + }, + }, nil +} + +// Type returns the provider type identifier. +func (p *Provider) Type() platform.ProviderType { + return platform.ProviderForgejo +} + +// --- RepositoryProvider --- + +func (p *Provider) GetRepository(ctx context.Context, owner, name string) (*platform.Repository, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/repos/{owner}/{repo} + return nil, fmt.Errorf("forgejo: GetRepository not yet implemented") +} + +func (p *Provider) GetRepositoryByID(ctx context.Context, id int64) (*platform.Repository, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/repositories/{id} + return nil, fmt.Errorf("forgejo: GetRepositoryByID not yet implemented") +} + +func (p *Provider) CreateRepositoryFromTemplate(ctx context.Context, templateID int64, opts platform.CreateRepoOptions) (*platform.Repository, error) { + // TODO: implement using Forgejo/Gitea API: POST /api/v1/repos/{template_owner}/{template_repo}/generate + return nil, fmt.Errorf("forgejo: CreateRepositoryFromTemplate not yet implemented") +} + +func (p *Provider) DeleteRepository(ctx context.Context, owner, name string) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/repos/{owner}/{repo} + return fmt.Errorf("forgejo: DeleteRepository not yet implemented") +} + +// --- UserProvider --- + +func (p *Provider) GetAuthenticatedUser(ctx context.Context) (*platform.User, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/user + return nil, fmt.Errorf("forgejo: GetAuthenticatedUser not yet implemented") +} + +func (p *Provider) GetUser(ctx context.Context, username string) (*platform.User, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/users/{username} + return nil, fmt.Errorf("forgejo: GetUser not yet implemented") +} + +func (p *Provider) GetUserByEmail(ctx context.Context, email string) (*platform.User, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/users/search?q={email} + return nil, fmt.Errorf("forgejo: GetUserByEmail not yet implemented") +} + +// --- OrganizationProvider --- + +func (p *Provider) GetOrganization(ctx context.Context, name string) (*platform.Organization, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/orgs/{org} + return nil, fmt.Errorf("forgejo: GetOrganization not yet implemented") +} + +func (p *Provider) CreateOrganization(ctx context.Context, opts platform.CreateOrgOptions) (*platform.Organization, error) { + // TODO: implement using Forgejo/Gitea API: POST /api/v1/orgs + return nil, fmt.Errorf("forgejo: CreateOrganization not yet implemented") +} + +func (p *Provider) DeleteOrganization(ctx context.Context, name string) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/orgs/{org} + return fmt.Errorf("forgejo: DeleteOrganization not yet implemented") +} + +// --- TeamProvider --- + +func (p *Provider) CreateTeam(ctx context.Context, opts platform.CreateTeamOptions) (*platform.Team, error) { + // TODO: implement using Forgejo/Gitea API: POST /api/v1/orgs/{org}/teams + return nil, fmt.Errorf("forgejo: CreateTeam not yet implemented") +} + +func (p *Provider) DeleteTeam(ctx context.Context, id int64) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id} + return fmt.Errorf("forgejo: DeleteTeam not yet implemented") +} + +func (p *Provider) AddTeamMember(ctx context.Context, teamID, userID int64) error { + // TODO: implement using Forgejo/Gitea API: PUT /api/v1/teams/{id}/members/{username} + return fmt.Errorf("forgejo: AddTeamMember not yet implemented") +} + +func (p *Provider) RemoveTeamMember(ctx context.Context, teamID, userID int64) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id}/members/{username} + return fmt.Errorf("forgejo: RemoveTeamMember not yet implemented") +} + +func (p *Provider) AddTeamRepository(ctx context.Context, teamID, repoID int64) error { + // TODO: implement using Forgejo/Gitea API: PUT /api/v1/teams/{id}/repos/{org}/{repo} + return fmt.Errorf("forgejo: AddTeamRepository not yet implemented") +} + +func (p *Provider) RemoveTeamRepository(ctx context.Context, teamID, repoID int64) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/teams/{id}/repos/{org}/{repo} + return fmt.Errorf("forgejo: RemoveTeamRepository not yet implemented") +} + +// --- CollaboratorProvider --- + +func (p *Provider) AddCollaborator(ctx context.Context, repoID, userID int64, perm platform.Permission) error { + // TODO: implement using Forgejo/Gitea API: PUT /api/v1/repos/{owner}/{repo}/collaborators/{collaborator} + return fmt.Errorf("forgejo: AddCollaborator not yet implemented") +} + +func (p *Provider) RemoveCollaborator(ctx context.Context, repoID, userID int64) error { + // TODO: implement using Forgejo/Gitea API: DELETE /api/v1/repos/{owner}/{repo}/collaborators/{collaborator} + return fmt.Errorf("forgejo: RemoveCollaborator not yet implemented") +} + +// --- BranchProvider --- + +func (p *Provider) ProtectBranch(ctx context.Context, repoID int64, protection platform.BranchProtection) error { + // TODO: implement using Forgejo/Gitea API: POST /api/v1/repos/{owner}/{repo}/branch_protections + return fmt.Errorf("forgejo: ProtectBranch not yet implemented") +} + +func (p *Provider) GetLatestCommit(ctx context.Context, owner, repo, branch string) (*platform.Commit, error) { + // TODO: implement using Forgejo/Gitea API: GET /api/v1/repos/{owner}/{repo}/git/commits?sha={branch}&limit=1 + return nil, fmt.Errorf("forgejo: GetLatestCommit not yet implemented") +} diff --git a/internal/platform/forgejo/forgejo_test.go b/internal/platform/forgejo/forgejo_test.go new file mode 100644 index 0000000..2f4bf2f --- /dev/null +++ b/internal/platform/forgejo/forgejo_test.go @@ -0,0 +1,177 @@ +package forgejo + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "code.forgejo.org/forgejo/classroom/internal/platform" +) + +func TestNew_Success(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + TimeoutSeconds: 10, + } + + provider, err := New(cfg) + require.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, platform.ProviderForgejo, provider.Type()) +} + +func TestNew_MissingBaseURL(t *testing.T) { + cfg := platform.ProviderConfig{ + Token: "test-token", + } + + provider, err := New(cfg) + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "base URL is required") +} + +func TestNew_MissingToken(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + } + + provider, err := New(cfg) + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "access token is required") +} + +func TestNew_DefaultTimeout(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + } + + provider, err := New(cfg) + require.NoError(t, err) + + p := provider.(*Provider) + assert.Equal(t, 30_000_000_000, int(p.httpClient.Timeout)) // 30s in nanoseconds +} + +func TestNew_CustomTimeout(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + TimeoutSeconds: 60, + } + + provider, err := New(cfg) + require.NoError(t, err) + + p := provider.(*Provider) + assert.Equal(t, 60_000_000_000, int(p.httpClient.Timeout)) // 60s in nanoseconds +} + +func TestProvider_ImplementsInterfaces(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + + // Verify Provider interface + var _ platform.Provider = provider + + // Verify CollaboratorProvider interface + var _ platform.CollaboratorProvider = provider.(*Provider) +} + +func TestProvider_Type(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + assert.Equal(t, platform.ProviderForgejo, provider.Type()) +} + +func TestProvider_MethodsReturnNotImplemented(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://forgejo.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + + ctx := context.Background() + p := provider.(*Provider) + + // RepositoryProvider + _, err = p.GetRepository(ctx, "owner", "repo") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not yet implemented") + + _, err = p.GetRepositoryByID(ctx, 1) + assert.Error(t, err) + + _, err = p.CreateRepositoryFromTemplate(ctx, 1, platform.CreateRepoOptions{}) + assert.Error(t, err) + + err = p.DeleteRepository(ctx, "owner", "repo") + assert.Error(t, err) + + // UserProvider + _, err = p.GetAuthenticatedUser(ctx) + assert.Error(t, err) + + _, err = p.GetUser(ctx, "user") + assert.Error(t, err) + + _, err = p.GetUserByEmail(ctx, "user@example.com") + assert.Error(t, err) + + // OrganizationProvider + _, err = p.GetOrganization(ctx, "org") + assert.Error(t, err) + + _, err = p.CreateOrganization(ctx, platform.CreateOrgOptions{}) + assert.Error(t, err) + + err = p.DeleteOrganization(ctx, "org") + assert.Error(t, err) + + // TeamProvider + _, err = p.CreateTeam(ctx, platform.CreateTeamOptions{}) + assert.Error(t, err) + + err = p.DeleteTeam(ctx, 1) + assert.Error(t, err) + + err = p.AddTeamMember(ctx, 1, 2) + assert.Error(t, err) + + err = p.RemoveTeamMember(ctx, 1, 2) + assert.Error(t, err) + + err = p.AddTeamRepository(ctx, 1, 2) + assert.Error(t, err) + + err = p.RemoveTeamRepository(ctx, 1, 2) + assert.Error(t, err) + + // CollaboratorProvider + err = p.AddCollaborator(ctx, 1, 2, platform.PermissionWrite) + assert.Error(t, err) + + err = p.RemoveCollaborator(ctx, 1, 2) + assert.Error(t, err) + + // BranchProvider + err = p.ProtectBranch(ctx, 1, platform.BranchProtection{}) + assert.Error(t, err) + + _, err = p.GetLatestCommit(ctx, "owner", "repo", "main") + assert.Error(t, err) +} diff --git a/internal/platform/gitlab/gitlab.go b/internal/platform/gitlab/gitlab.go new file mode 100644 index 0000000..84c0b57 --- /dev/null +++ b/internal/platform/gitlab/gitlab.go @@ -0,0 +1,180 @@ +// Package gitlab implements the platform.Provider interface for GitLab +// instances (both gitlab.com and self-hosted). +// +// GitLab terminology mapping: +// - Organization -> Group +// - Repository -> Project +// - Team -> Subgroup or Group with project access +// - Collaborator -> Project Member +package gitlab + +import ( + "context" + "fmt" + "net/http" + "time" + + "code.forgejo.org/forgejo/classroom/internal/platform" +) + +// Provider implements platform.Provider for GitLab instances. +// It also implements platform.CollaboratorProvider via project members. +type Provider struct { + baseURL string + token string + httpClient *http.Client +} + +// Ensure compile-time interface compliance. +var ( + _ platform.Provider = (*Provider)(nil) + _ platform.CollaboratorProvider = (*Provider)(nil) +) + +// New creates a new GitLab provider from the given platform config. +func New(cfg platform.ProviderConfig) (platform.Provider, error) { + if cfg.BaseURL == "" { + return nil, fmt.Errorf("gitlab: base URL is required") + } + if cfg.Token == "" { + return nil, fmt.Errorf("gitlab: access token is required") + } + + timeout := 30 * time.Second + if cfg.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.TimeoutSeconds) * time.Second + } + + return &Provider{ + baseURL: cfg.BaseURL, + token: cfg.Token, + httpClient: &http.Client{ + Timeout: timeout, + }, + }, nil +} + +// Type returns the provider type identifier. +func (p *Provider) Type() platform.ProviderType { + return platform.ProviderGitLab +} + +// --- RepositoryProvider (GitLab: Projects) --- + +func (p *Provider) GetRepository(ctx context.Context, owner, name string) (*platform.Repository, error) { + // TODO: implement using GitLab API: GET /api/v4/projects/:id (URL-encoded path) + // GitLab uses "namespace/project" encoded as URL path. + return nil, fmt.Errorf("gitlab: GetRepository not yet implemented") +} + +func (p *Provider) GetRepositoryByID(ctx context.Context, id int64) (*platform.Repository, error) { + // TODO: implement using GitLab API: GET /api/v4/projects/:id + return nil, fmt.Errorf("gitlab: GetRepositoryByID not yet implemented") +} + +func (p *Provider) CreateRepositoryFromTemplate(ctx context.Context, templateID int64, opts platform.CreateRepoOptions) (*platform.Repository, error) { + // TODO: implement using GitLab API: POST /api/v4/projects (with template) + // GitLab uses "use_custom_template" or fork + clear approach. + return nil, fmt.Errorf("gitlab: CreateRepositoryFromTemplate not yet implemented") +} + +func (p *Provider) DeleteRepository(ctx context.Context, owner, name string) error { + // TODO: implement using GitLab API: DELETE /api/v4/projects/:id + return fmt.Errorf("gitlab: DeleteRepository not yet implemented") +} + +// --- UserProvider --- + +func (p *Provider) GetAuthenticatedUser(ctx context.Context) (*platform.User, error) { + // TODO: implement using GitLab API: GET /api/v4/user + return nil, fmt.Errorf("gitlab: GetAuthenticatedUser not yet implemented") +} + +func (p *Provider) GetUser(ctx context.Context, username string) (*platform.User, error) { + // TODO: implement using GitLab API: GET /api/v4/users?username={username} + return nil, fmt.Errorf("gitlab: GetUser not yet implemented") +} + +func (p *Provider) GetUserByEmail(ctx context.Context, email string) (*platform.User, error) { + // TODO: implement using GitLab API: GET /api/v4/users?search={email} + return nil, fmt.Errorf("gitlab: GetUserByEmail not yet implemented") +} + +// --- OrganizationProvider (GitLab: Groups) --- + +func (p *Provider) GetOrganization(ctx context.Context, name string) (*platform.Organization, error) { + // TODO: implement using GitLab API: GET /api/v4/groups/:id (URL-encoded path) + return nil, fmt.Errorf("gitlab: GetOrganization not yet implemented") +} + +func (p *Provider) CreateOrganization(ctx context.Context, opts platform.CreateOrgOptions) (*platform.Organization, error) { + // TODO: implement using GitLab API: POST /api/v4/groups + return nil, fmt.Errorf("gitlab: CreateOrganization not yet implemented") +} + +func (p *Provider) DeleteOrganization(ctx context.Context, name string) error { + // TODO: implement using GitLab API: DELETE /api/v4/groups/:id + return fmt.Errorf("gitlab: DeleteOrganization not yet implemented") +} + +// --- TeamProvider (GitLab: Subgroups / Share with group) --- + +func (p *Provider) CreateTeam(ctx context.Context, opts platform.CreateTeamOptions) (*platform.Team, error) { + // TODO: implement using GitLab API: POST /api/v4/groups + // Teams in GitLab map to subgroups under the parent group. + return nil, fmt.Errorf("gitlab: CreateTeam not yet implemented") +} + +func (p *Provider) DeleteTeam(ctx context.Context, id int64) error { + // TODO: implement using GitLab API: DELETE /api/v4/groups/:id + return fmt.Errorf("gitlab: DeleteTeam not yet implemented") +} + +func (p *Provider) AddTeamMember(ctx context.Context, teamID, userID int64) error { + // TODO: implement using GitLab API: POST /api/v4/groups/:id/members + return fmt.Errorf("gitlab: AddTeamMember not yet implemented") +} + +func (p *Provider) RemoveTeamMember(ctx context.Context, teamID, userID int64) error { + // TODO: implement using GitLab API: DELETE /api/v4/groups/:id/members/:user_id + return fmt.Errorf("gitlab: RemoveTeamMember not yet implemented") +} + +func (p *Provider) AddTeamRepository(ctx context.Context, teamID, repoID int64) error { + // TODO: implement using GitLab API: POST /api/v4/projects/:id/share + // Shares project with the group (team). + return fmt.Errorf("gitlab: AddTeamRepository not yet implemented") +} + +func (p *Provider) RemoveTeamRepository(ctx context.Context, teamID, repoID int64) error { + // TODO: implement using GitLab API: DELETE /api/v4/projects/:id/share/:group_id + return fmt.Errorf("gitlab: RemoveTeamRepository not yet implemented") +} + +// --- CollaboratorProvider (GitLab: Project Members) --- + +func (p *Provider) AddCollaborator(ctx context.Context, repoID, userID int64, perm platform.Permission) error { + // TODO: implement using GitLab API: POST /api/v4/projects/:id/members + // Map platform.Permission to GitLab access levels: + // Read -> Guest (10) or Reporter (20) + // Write -> Developer (30) + // Admin -> Maintainer (40) + return fmt.Errorf("gitlab: AddCollaborator not yet implemented") +} + +func (p *Provider) RemoveCollaborator(ctx context.Context, repoID, userID int64) error { + // TODO: implement using GitLab API: DELETE /api/v4/projects/:id/members/:user_id + return fmt.Errorf("gitlab: RemoveCollaborator not yet implemented") +} + +// --- BranchProvider --- + +func (p *Provider) ProtectBranch(ctx context.Context, repoID int64, protection platform.BranchProtection) error { + // TODO: implement using GitLab API: POST /api/v4/projects/:id/protected_branches + return fmt.Errorf("gitlab: ProtectBranch not yet implemented") +} + +func (p *Provider) GetLatestCommit(ctx context.Context, owner, repo, branch string) (*platform.Commit, error) { + // TODO: implement using GitLab API: GET /api/v4/projects/:id/repository/commits?ref_name={branch}&per_page=1 + return nil, fmt.Errorf("gitlab: GetLatestCommit not yet implemented") +} diff --git a/internal/platform/gitlab/gitlab_test.go b/internal/platform/gitlab/gitlab_test.go new file mode 100644 index 0000000..ca6193c --- /dev/null +++ b/internal/platform/gitlab/gitlab_test.go @@ -0,0 +1,177 @@ +package gitlab + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "code.forgejo.org/forgejo/classroom/internal/platform" +) + +func TestNew_Success(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + TimeoutSeconds: 10, + } + + provider, err := New(cfg) + require.NoError(t, err) + assert.NotNil(t, provider) + assert.Equal(t, platform.ProviderGitLab, provider.Type()) +} + +func TestNew_MissingBaseURL(t *testing.T) { + cfg := platform.ProviderConfig{ + Token: "test-token", + } + + provider, err := New(cfg) + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "base URL is required") +} + +func TestNew_MissingToken(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + } + + provider, err := New(cfg) + assert.Error(t, err) + assert.Nil(t, provider) + assert.Contains(t, err.Error(), "access token is required") +} + +func TestNew_DefaultTimeout(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + } + + provider, err := New(cfg) + require.NoError(t, err) + + p := provider.(*Provider) + assert.Equal(t, 30_000_000_000, int(p.httpClient.Timeout)) // 30s in nanoseconds +} + +func TestNew_CustomTimeout(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + TimeoutSeconds: 60, + } + + provider, err := New(cfg) + require.NoError(t, err) + + p := provider.(*Provider) + assert.Equal(t, 60_000_000_000, int(p.httpClient.Timeout)) // 60s in nanoseconds +} + +func TestProvider_ImplementsInterfaces(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + + // Verify Provider interface + var _ platform.Provider = provider + + // Verify CollaboratorProvider interface + var _ platform.CollaboratorProvider = provider.(*Provider) +} + +func TestProvider_Type(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + assert.Equal(t, platform.ProviderGitLab, provider.Type()) +} + +func TestProvider_MethodsReturnNotImplemented(t *testing.T) { + cfg := platform.ProviderConfig{ + BaseURL: "https://gitlab.example.com", + Token: "test-token", + } + provider, err := New(cfg) + require.NoError(t, err) + + ctx := context.Background() + p := provider.(*Provider) + + // RepositoryProvider + _, err = p.GetRepository(ctx, "owner", "repo") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not yet implemented") + + _, err = p.GetRepositoryByID(ctx, 1) + assert.Error(t, err) + + _, err = p.CreateRepositoryFromTemplate(ctx, 1, platform.CreateRepoOptions{}) + assert.Error(t, err) + + err = p.DeleteRepository(ctx, "owner", "repo") + assert.Error(t, err) + + // UserProvider + _, err = p.GetAuthenticatedUser(ctx) + assert.Error(t, err) + + _, err = p.GetUser(ctx, "user") + assert.Error(t, err) + + _, err = p.GetUserByEmail(ctx, "user@example.com") + assert.Error(t, err) + + // OrganizationProvider + _, err = p.GetOrganization(ctx, "org") + assert.Error(t, err) + + _, err = p.CreateOrganization(ctx, platform.CreateOrgOptions{}) + assert.Error(t, err) + + err = p.DeleteOrganization(ctx, "org") + assert.Error(t, err) + + // TeamProvider + _, err = p.CreateTeam(ctx, platform.CreateTeamOptions{}) + assert.Error(t, err) + + err = p.DeleteTeam(ctx, 1) + assert.Error(t, err) + + err = p.AddTeamMember(ctx, 1, 2) + assert.Error(t, err) + + err = p.RemoveTeamMember(ctx, 1, 2) + assert.Error(t, err) + + err = p.AddTeamRepository(ctx, 1, 2) + assert.Error(t, err) + + err = p.RemoveTeamRepository(ctx, 1, 2) + assert.Error(t, err) + + // CollaboratorProvider + err = p.AddCollaborator(ctx, 1, 2, platform.PermissionWrite) + assert.Error(t, err) + + err = p.RemoveCollaborator(ctx, 1, 2) + assert.Error(t, err) + + // BranchProvider + err = p.ProtectBranch(ctx, 1, platform.BranchProtection{}) + assert.Error(t, err) + + _, err = p.GetLatestCommit(ctx, "owner", "repo", "main") + assert.Error(t, err) +} diff --git a/internal/platform/provider.go b/internal/platform/provider.go new file mode 100644 index 0000000..4374c38 --- /dev/null +++ b/internal/platform/provider.go @@ -0,0 +1,99 @@ +package platform + +import "context" + +// Provider defines the interface that every git hosting platform backend +// must implement. The interface is split into sub-interfaces grouped by +// domain so that consumers can depend on only the subset they need. +type Provider interface { + RepositoryProvider + UserProvider + OrganizationProvider + TeamProvider + BranchProvider + + // Type returns the platform type (e.g. "forgejo", "gitlab"). + Type() ProviderType +} + +// RepositoryProvider groups operations on git repositories. +type RepositoryProvider interface { + // GetRepository returns a repository by its owner and name. + GetRepository(ctx context.Context, owner, name string) (*Repository, error) + + // GetRepositoryByID returns a repository by its platform ID. + GetRepositoryByID(ctx context.Context, id int64) (*Repository, error) + + // CreateRepositoryFromTemplate creates a new repository using a + // template repository as a base. + CreateRepositoryFromTemplate(ctx context.Context, templateID int64, opts CreateRepoOptions) (*Repository, error) + + // DeleteRepository permanently deletes a repository. + DeleteRepository(ctx context.Context, owner, name string) error +} + +// UserProvider groups operations on user accounts. +type UserProvider interface { + // GetAuthenticatedUser returns the currently authenticated user. + GetAuthenticatedUser(ctx context.Context) (*User, error) + + // GetUser returns a user by username. + GetUser(ctx context.Context, username string) (*User, error) + + // GetUserByEmail returns a user by email address. + // Returns ErrNotFound if no user matches. + GetUserByEmail(ctx context.Context, email string) (*User, error) +} + +// OrganizationProvider groups operations on organizations/groups. +type OrganizationProvider interface { + // GetOrganization returns an organization by name. + GetOrganization(ctx context.Context, name string) (*Organization, error) + + // CreateOrganization creates a new organization. + CreateOrganization(ctx context.Context, opts CreateOrgOptions) (*Organization, error) + + // DeleteOrganization deletes an organization. + DeleteOrganization(ctx context.Context, name string) error +} + +// TeamProvider groups operations on teams within an organization. +type TeamProvider interface { + // CreateTeam creates a new team within an organization. + CreateTeam(ctx context.Context, opts CreateTeamOptions) (*Team, error) + + // DeleteTeam deletes a team by ID. + DeleteTeam(ctx context.Context, id int64) error + + // AddTeamMember adds a user to a team. + AddTeamMember(ctx context.Context, teamID, userID int64) error + + // RemoveTeamMember removes a user from a team. + RemoveTeamMember(ctx context.Context, teamID, userID int64) error + + // AddTeamRepository grants a team access to a repository. + AddTeamRepository(ctx context.Context, teamID, repoID int64) error + + // RemoveTeamRepository revokes a team's access to a repository. + RemoveTeamRepository(ctx context.Context, teamID, repoID int64) error +} + +// CollaboratorProvider groups operations on individual repository +// collaborators. Not all platforms support this directly (GitLab uses +// project members), so it is kept separate from the core Provider. +type CollaboratorProvider interface { + // AddCollaborator grants a user access to a repository. + AddCollaborator(ctx context.Context, repoID, userID int64, perm Permission) error + + // RemoveCollaborator revokes a user's access to a repository. + RemoveCollaborator(ctx context.Context, repoID, userID int64) error +} + +// BranchProvider groups operations on branch management. +type BranchProvider interface { + // ProtectBranch applies protection rules to a branch. + ProtectBranch(ctx context.Context, repoID int64, protection BranchProtection) error + + // GetLatestCommit returns the most recent commit on a branch. + GetLatestCommit(ctx context.Context, owner, repo, branch string) (*Commit, error) +} diff --git a/internal/platform/registry.go b/internal/platform/registry.go new file mode 100644 index 0000000..2df46d2 --- /dev/null +++ b/internal/platform/registry.go @@ -0,0 +1,72 @@ +package platform + +import ( + "fmt" + "sync" +) + +// ProviderFactory is a function that constructs a Provider from a +// platform-specific configuration. +type ProviderFactory func(cfg ProviderConfig) (Provider, error) + +// ProviderConfig holds the common connection parameters shared by all +// platform backends. +type ProviderConfig struct { + BaseURL string + Token string + TimeoutSeconds int + RequestsPerMinute int + BurstSize int +} + +// Registry manages the set of available platform provider factories. +// It is safe for concurrent use. +type Registry struct { + mu sync.RWMutex + factories map[ProviderType]ProviderFactory +} + +// NewRegistry creates an empty provider registry. +func NewRegistry() *Registry { + return &Registry{ + factories: make(map[ProviderType]ProviderFactory), + } +} + +// Register adds a provider factory for the given platform type. +// It returns an error if a factory is already registered for that type. +func (r *Registry) Register(pt ProviderType, factory ProviderFactory) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.factories[pt]; exists { + return fmt.Errorf("platform: provider %q is already registered", pt) + } + r.factories[pt] = factory + return nil +} + +// Create instantiates a provider for the given platform type using the +// supplied configuration. +func (r *Registry) Create(pt ProviderType, cfg ProviderConfig) (Provider, error) { + r.mu.RLock() + factory, exists := r.factories[pt] + r.mu.RUnlock() + + if !exists { + return nil, fmt.Errorf("platform: unknown provider type %q", pt) + } + return factory(cfg) +} + +// Available returns the list of registered provider types. +func (r *Registry) Available() []ProviderType { + r.mu.RLock() + defer r.mu.RUnlock() + + types := make([]ProviderType, 0, len(r.factories)) + for pt := range r.factories { + types = append(types, pt) + } + return types +} diff --git a/internal/platform/registry_test.go b/internal/platform/registry_test.go new file mode 100644 index 0000000..e5848e3 --- /dev/null +++ b/internal/platform/registry_test.go @@ -0,0 +1,129 @@ +package platform + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubProvider is a minimal Provider implementation for testing the registry. +type stubProvider struct { + providerType ProviderType +} + +func (s *stubProvider) Type() ProviderType { return s.providerType } + +// RepositoryProvider stubs +func (s *stubProvider) GetRepository(_ context.Context, _, _ string) (*Repository, error) { + return nil, nil +} +func (s *stubProvider) GetRepositoryByID(_ context.Context, _ int64) (*Repository, error) { + return nil, nil +} +func (s *stubProvider) CreateRepositoryFromTemplate(_ context.Context, _ int64, _ CreateRepoOptions) (*Repository, error) { + return nil, nil +} +func (s *stubProvider) DeleteRepository(_ context.Context, _, _ string) error { return nil } + +// UserProvider stubs +func (s *stubProvider) GetAuthenticatedUser(_ context.Context) (*User, error) { return nil, nil } +func (s *stubProvider) GetUser(_ context.Context, _ string) (*User, error) { return nil, nil } +func (s *stubProvider) GetUserByEmail(_ context.Context, _ string) (*User, error) { return nil, nil } + +// OrganizationProvider stubs +func (s *stubProvider) GetOrganization(_ context.Context, _ string) (*Organization, error) { + return nil, nil +} +func (s *stubProvider) CreateOrganization(_ context.Context, _ CreateOrgOptions) (*Organization, error) { + return nil, nil +} +func (s *stubProvider) DeleteOrganization(_ context.Context, _ string) error { return nil } + +// TeamProvider stubs +func (s *stubProvider) CreateTeam(_ context.Context, _ CreateTeamOptions) (*Team, error) { + return nil, nil +} +func (s *stubProvider) DeleteTeam(_ context.Context, _ int64) error { return nil } +func (s *stubProvider) AddTeamMember(_ context.Context, _, _ int64) error { return nil } +func (s *stubProvider) RemoveTeamMember(_ context.Context, _, _ int64) error { return nil } +func (s *stubProvider) AddTeamRepository(_ context.Context, _, _ int64) error { + return nil +} +func (s *stubProvider) RemoveTeamRepository(_ context.Context, _, _ int64) error { + return nil +} + +// BranchProvider stubs +func (s *stubProvider) ProtectBranch(_ context.Context, _ int64, _ BranchProtection) error { + return nil +} +func (s *stubProvider) GetLatestCommit(_ context.Context, _, _, _ string) (*Commit, error) { + return nil, nil +} + +func newStubFactory(pt ProviderType) ProviderFactory { + return func(cfg ProviderConfig) (Provider, error) { + return &stubProvider{providerType: pt}, nil + } +} + +func TestRegistry_RegisterAndCreate(t *testing.T) { + reg := NewRegistry() + + err := reg.Register(ProviderForgejo, newStubFactory(ProviderForgejo)) + require.NoError(t, err) + + provider, err := reg.Create(ProviderForgejo, ProviderConfig{}) + require.NoError(t, err) + assert.Equal(t, ProviderForgejo, provider.Type()) +} + +func TestRegistry_RegisterDuplicate(t *testing.T) { + reg := NewRegistry() + + err := reg.Register(ProviderForgejo, newStubFactory(ProviderForgejo)) + require.NoError(t, err) + + err = reg.Register(ProviderForgejo, newStubFactory(ProviderForgejo)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestRegistry_CreateUnknownProvider(t *testing.T) { + reg := NewRegistry() + + _, err := reg.Create("unknown", ProviderConfig{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown provider type") +} + +func TestRegistry_Available(t *testing.T) { + reg := NewRegistry() + + assert.Empty(t, reg.Available()) + + _ = reg.Register(ProviderForgejo, newStubFactory(ProviderForgejo)) + _ = reg.Register(ProviderGitLab, newStubFactory(ProviderGitLab)) + + available := reg.Available() + assert.Len(t, available, 2) + assert.Contains(t, available, ProviderForgejo) + assert.Contains(t, available, ProviderGitLab) +} + +func TestRegistry_MultipleProviders(t *testing.T) { + reg := NewRegistry() + + _ = reg.Register(ProviderForgejo, newStubFactory(ProviderForgejo)) + _ = reg.Register(ProviderGitLab, newStubFactory(ProviderGitLab)) + + forgejo, err := reg.Create(ProviderForgejo, ProviderConfig{}) + require.NoError(t, err) + assert.Equal(t, ProviderForgejo, forgejo.Type()) + + gitlab, err := reg.Create(ProviderGitLab, ProviderConfig{}) + require.NoError(t, err) + assert.Equal(t, ProviderGitLab, gitlab.Type()) +} diff --git a/internal/platform/types.go b/internal/platform/types.go new file mode 100644 index 0000000..97a7ecd --- /dev/null +++ b/internal/platform/types.go @@ -0,0 +1,102 @@ +// Package platform defines platform-agnostic types and interfaces for +// interacting with git hosting providers (Forgejo, GitLab, etc.). +package platform + +import "time" + +// ProviderType identifies which git hosting platform a provider implements. +type ProviderType string + +const ( + ProviderForgejo ProviderType = "forgejo" + ProviderGitLab ProviderType = "gitlab" +) + +// Permission represents the access level granted to a user on a repository. +type Permission string + +const ( + PermissionRead Permission = "read" + PermissionWrite Permission = "write" + PermissionAdmin Permission = "admin" +) + +// Repository represents a git repository on the platform. +type Repository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` + Private bool `json:"private"` + Empty bool `json:"empty"` +} + +// CreateRepoOptions contains the parameters for creating a repository from +// a template. +type CreateRepoOptions struct { + Owner string + Name string + Description string + Private bool + TemplateID int64 +} + +// User represents a user account on the platform. +type User struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + AvatarURL string `json:"avatar_url"` + IsAdmin bool `json:"is_admin"` +} + +// Organization represents a group or organization on the platform. +type Organization struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + AvatarURL string `json:"avatar_url"` +} + +// CreateOrgOptions contains the parameters for creating an organization. +type CreateOrgOptions struct { + Name string + FullName string + Description string +} + +// Team represents a team within an organization on the platform. +type Team struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permission Permission `json:"permission"` +} + +// CreateTeamOptions contains the parameters for creating a team. +type CreateTeamOptions struct { + OrgName string + Name string + Description string + Permission Permission +} + +// BranchProtection represents branch protection rules on the platform. +type BranchProtection struct { + BranchName string + EnablePush bool + EnablePushWhitelist bool + PushWhitelistUsernames []string +} + +// Commit represents a git commit on the platform. +type Commit struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/platform/types_test.go b/internal/platform/types_test.go new file mode 100644 index 0000000..b000600 --- /dev/null +++ b/internal/platform/types_test.go @@ -0,0 +1,18 @@ +package platform + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProviderTypeConstants(t *testing.T) { + assert.Equal(t, ProviderType("forgejo"), ProviderForgejo) + assert.Equal(t, ProviderType("gitlab"), ProviderGitLab) +} + +func TestPermissionConstants(t *testing.T) { + assert.Equal(t, Permission("read"), PermissionRead) + assert.Equal(t, Permission("write"), PermissionWrite) + assert.Equal(t, Permission("admin"), PermissionAdmin) +} diff --git a/internal/repository/assignment.go b/internal/repository/assignment.go new file mode 100644 index 0000000..774d3bb --- /dev/null +++ b/internal/repository/assignment.go @@ -0,0 +1,232 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "code.forgejo.org/forgejo/classroom/internal/model" +) + +// AssignmentRepository defines data-access operations for assignments. +type AssignmentRepository interface { + Create(ctx context.Context, a *model.Assignment) (*model.Assignment, error) + GetByID(ctx context.Context, id int64) (*model.Assignment, error) + GetByClassroomAndSlug(ctx context.Context, classroomID int64, slug string) (*model.Assignment, error) + List(ctx context.Context, filter AssignmentFilter, pg Pagination) ([]model.Assignment, int, error) + Update(ctx context.Context, id int64, req *model.UpdateAssignmentRequest) (*model.Assignment, error) + Delete(ctx context.Context, id int64) error + GetStats(ctx context.Context, id int64) (*model.AssignmentStats, error) +} + +// AssignmentFilter holds optional filters for listing assignments. +type AssignmentFilter struct { + ClassroomID int64 + Status string // "active", "past", or "" for all +} + +type assignmentRepo struct { + db DBTX +} + +// NewAssignmentRepository creates a new PostgreSQL-backed AssignmentRepository. +func NewAssignmentRepository(db DBTX) AssignmentRepository { + return &assignmentRepo{db: db} +} + +const assignmentColumns = `id, classroom_id, name, slug, description, + template_repository, template_repository_id, deadline, + max_team_size, auto_accept, public, created_at, updated_at` + +func scanAssignment(row interface{ Scan(...interface{}) error }, a *model.Assignment) error { + return row.Scan( + &a.ID, &a.ClassroomID, &a.Name, &a.Slug, &a.Description, + &a.TemplateRepository, &a.TemplateRepositoryID, &a.Deadline, + &a.MaxTeamSize, &a.AutoAccept, &a.Public, &a.CreatedAt, &a.UpdatedAt, + ) +} + +func (r *assignmentRepo) Create(ctx context.Context, a *model.Assignment) (*model.Assignment, error) { + query := ` + INSERT INTO assignments (classroom_id, name, slug, description, + template_repository, template_repository_id, deadline, + max_team_size, auto_accept, public) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING ` + assignmentColumns + + err := scanAssignment(r.db.QueryRowContext(ctx, query, + a.ClassroomID, a.Name, a.Slug, a.Description, + a.TemplateRepository, a.TemplateRepositoryID, a.Deadline, + a.MaxTeamSize, a.AutoAccept, a.Public, + ), a) + if err != nil { + return nil, fmt.Errorf("create assignment: %w", err) + } + return a, nil +} + +func (r *assignmentRepo) GetByID(ctx context.Context, id int64) (*model.Assignment, error) { + query := "SELECT " + assignmentColumns + " FROM assignments WHERE id = $1" + a := &model.Assignment{} + err := scanAssignment(r.db.QueryRowContext(ctx, query, id), a) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get assignment by id: %w", err) + } + return a, nil +} + +func (r *assignmentRepo) GetByClassroomAndSlug(ctx context.Context, classroomID int64, slug string) (*model.Assignment, error) { + query := "SELECT " + assignmentColumns + " FROM assignments WHERE classroom_id = $1 AND slug = $2" + a := &model.Assignment{} + err := scanAssignment(r.db.QueryRowContext(ctx, query, classroomID, slug), a) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get assignment by classroom and slug: %w", err) + } + return a, nil +} + +func (r *assignmentRepo) List(ctx context.Context, filter AssignmentFilter, pg Pagination) ([]model.Assignment, int, error) { + where := "WHERE 1=1" + args := []interface{}{} + argIdx := 1 + + if filter.ClassroomID > 0 { + where += fmt.Sprintf(" AND classroom_id = $%d", argIdx) + args = append(args, filter.ClassroomID) + argIdx++ + } + switch filter.Status { + case "active": + where += " AND (deadline IS NULL OR deadline > NOW())" + case "past": + where += " AND deadline IS NOT NULL AND deadline <= NOW()" + } + + var total int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM assignments "+where, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count assignments: %w", err) + } + + dataQuery := fmt.Sprintf("SELECT %s FROM assignments %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", + assignmentColumns, where, argIdx, argIdx+1) + args = append(args, pg.Limit(), pg.Offset()) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("list assignments: %w", err) + } + defer rows.Close() + + var assignments []model.Assignment + for rows.Next() { + var a model.Assignment + if err := scanAssignment(rows, &a); err != nil { + return nil, 0, fmt.Errorf("scan assignment: %w", err) + } + assignments = append(assignments, a) + } + return assignments, total, rows.Err() +} + +func (r *assignmentRepo) Update(ctx context.Context, id int64, req *model.UpdateAssignmentRequest) (*model.Assignment, error) { + sets := []string{} + args := []interface{}{} + argIdx := 1 + + if req.Name != nil { + sets = append(sets, fmt.Sprintf("name = $%d", argIdx)) + args = append(args, *req.Name) + argIdx++ + } + if req.Description != nil { + sets = append(sets, fmt.Sprintf("description = $%d", argIdx)) + args = append(args, *req.Description) + argIdx++ + } + if req.Deadline != nil { + sets = append(sets, fmt.Sprintf("deadline = $%d", argIdx)) + args = append(args, *req.Deadline) + argIdx++ + } + if req.MaxTeamSize != nil { + sets = append(sets, fmt.Sprintf("max_team_size = $%d", argIdx)) + args = append(args, *req.MaxTeamSize) + argIdx++ + } + if req.AutoAccept != nil { + sets = append(sets, fmt.Sprintf("auto_accept = $%d", argIdx)) + args = append(args, *req.AutoAccept) + argIdx++ + } + if req.Public != nil { + sets = append(sets, fmt.Sprintf("public = $%d", argIdx)) + args = append(args, *req.Public) + argIdx++ + } + + if len(sets) == 0 { + return r.GetByID(ctx, id) + } + + sets = append(sets, "updated_at = NOW()") + query := fmt.Sprintf("UPDATE assignments SET %s WHERE id = $%d RETURNING %s", + joinStrings(sets, ", "), argIdx, assignmentColumns) + args = append(args, id) + + a := &model.Assignment{} + err := scanAssignment(r.db.QueryRowContext(ctx, query, args...), a) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("update assignment: %w", err) + } + return a, nil +} + +func (r *assignmentRepo) Delete(ctx context.Context, id int64) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM assignments WHERE id = $1", id) + if err != nil { + return fmt.Errorf("delete assignment: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("delete assignment: not found") + } + return nil +} + +func (r *assignmentRepo) GetStats(ctx context.Context, id int64) (*model.AssignmentStats, error) { + query := ` + SELECT + $1::bigint AS assignment_id, + COALESCE((SELECT COUNT(*) FROM roster_entries re + JOIN assignments a ON a.classroom_id = re.classroom_id + WHERE a.id = $1), 0) AS total_students, + COALESCE((SELECT COUNT(*) FROM submissions + WHERE assignment_id = $1 AND status = 'accepted'), 0) AS accepted_count, + COALESCE((SELECT COUNT(*) FROM submissions + WHERE assignment_id = $1), 0) AS submission_count, + COALESCE((SELECT COUNT(*) FROM teams + WHERE assignment_id = $1), 0) AS team_count` + + s := &model.AssignmentStats{} + err := r.db.QueryRowContext(ctx, query, id).Scan( + &s.AssignmentID, &s.TotalStudents, &s.AcceptedCount, + &s.SubmissionCount, &s.TeamCount, + ) + if err != nil { + return nil, fmt.Errorf("get assignment stats: %w", err) + } + if s.TotalStudents > 0 { + s.AcceptanceRate = float64(s.AcceptedCount) / float64(s.TotalStudents) + s.SubmissionRate = float64(s.SubmissionCount) / float64(s.TotalStudents) + } + return s, nil +} diff --git a/internal/repository/classroom.go b/internal/repository/classroom.go new file mode 100644 index 0000000..08d518a --- /dev/null +++ b/internal/repository/classroom.go @@ -0,0 +1,270 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "code.forgejo.org/forgejo/classroom/internal/model" +) + +// ClassroomRepository defines data-access operations for classrooms. +type ClassroomRepository interface { + Create(ctx context.Context, c *model.Classroom) (*model.Classroom, error) + GetByID(ctx context.Context, id int64) (*model.Classroom, error) + GetBySlug(ctx context.Context, slug string) (*model.Classroom, error) + List(ctx context.Context, filter ClassroomFilter, pg Pagination) ([]model.Classroom, int, error) + Update(ctx context.Context, id int64, req *model.UpdateClassroomRequest) (*model.Classroom, error) + Delete(ctx context.Context, id int64) error + Archive(ctx context.Context, id int64) (*model.Classroom, error) + GetStats(ctx context.Context, id int64) (*model.ClassroomStats, error) +} + +// ClassroomFilter holds optional filters for listing classrooms. +type ClassroomFilter struct { + OrganizationName string + InstructorID *int64 + IncludeArchived bool +} + +// classroomRepo is the PostgreSQL implementation of ClassroomRepository. +type classroomRepo struct { + db DBTX +} + +// NewClassroomRepository creates a new PostgreSQL-backed ClassroomRepository. +func NewClassroomRepository(db DBTX) ClassroomRepository { + return &classroomRepo{db: db} +} + +func (r *classroomRepo) Create(ctx context.Context, c *model.Classroom) (*model.Classroom, error) { + query := ` + INSERT INTO classrooms (name, slug, description, organization_name, + organization_id, instructor_id, instructor_login, public) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at, updated_at` + + err := r.db.QueryRowContext(ctx, query, + c.Name, c.Slug, c.Description, c.OrganizationName, + c.OrganizationID, c.InstructorID, c.InstructorLogin, c.Public, + ).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("create classroom: %w", err) + } + return c, nil +} + +func (r *classroomRepo) GetByID(ctx context.Context, id int64) (*model.Classroom, error) { + query := `SELECT id, name, slug, description, organization_name, + organization_id, instructor_id, instructor_login, public, archived, + created_at, updated_at, archived_at + FROM classrooms WHERE id = $1` + + c := &model.Classroom{} + err := r.db.QueryRowContext(ctx, query, id).Scan( + &c.ID, &c.Name, &c.Slug, &c.Description, &c.OrganizationName, + &c.OrganizationID, &c.InstructorID, &c.InstructorLogin, &c.Public, + &c.Archived, &c.CreatedAt, &c.UpdatedAt, &c.ArchivedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get classroom by id: %w", err) + } + return c, nil +} + +func (r *classroomRepo) GetBySlug(ctx context.Context, slug string) (*model.Classroom, error) { + query := `SELECT id, name, slug, description, organization_name, + organization_id, instructor_id, instructor_login, public, archived, + created_at, updated_at, archived_at + FROM classrooms WHERE slug = $1` + + c := &model.Classroom{} + err := r.db.QueryRowContext(ctx, query, slug).Scan( + &c.ID, &c.Name, &c.Slug, &c.Description, &c.OrganizationName, + &c.OrganizationID, &c.InstructorID, &c.InstructorLogin, &c.Public, + &c.Archived, &c.CreatedAt, &c.UpdatedAt, &c.ArchivedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get classroom by slug: %w", err) + } + return c, nil +} + +func (r *classroomRepo) List(ctx context.Context, filter ClassroomFilter, pg Pagination) ([]model.Classroom, int, error) { + where := "WHERE 1=1" + args := []interface{}{} + argIdx := 1 + + if !filter.IncludeArchived { + where += " AND archived = false" + } + if filter.OrganizationName != "" { + where += fmt.Sprintf(" AND organization_name = $%d", argIdx) + args = append(args, filter.OrganizationName) + argIdx++ + } + if filter.InstructorID != nil { + where += fmt.Sprintf(" AND instructor_id = $%d", argIdx) + args = append(args, *filter.InstructorID) + argIdx++ + } + + // Count total + countQuery := "SELECT COUNT(*) FROM classrooms " + where + var total int + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count classrooms: %w", err) + } + + // Fetch page + dataQuery := fmt.Sprintf(`SELECT id, name, slug, description, organization_name, + organization_id, instructor_id, instructor_login, public, archived, + created_at, updated_at, archived_at + FROM classrooms %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, + where, argIdx, argIdx+1) + args = append(args, pg.Limit(), pg.Offset()) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("list classrooms: %w", err) + } + defer rows.Close() + + var classrooms []model.Classroom + for rows.Next() { + var c model.Classroom + if err := rows.Scan( + &c.ID, &c.Name, &c.Slug, &c.Description, &c.OrganizationName, + &c.OrganizationID, &c.InstructorID, &c.InstructorLogin, &c.Public, + &c.Archived, &c.CreatedAt, &c.UpdatedAt, &c.ArchivedAt, + ); err != nil { + return nil, 0, fmt.Errorf("scan classroom: %w", err) + } + classrooms = append(classrooms, c) + } + return classrooms, total, rows.Err() +} + +func (r *classroomRepo) Update(ctx context.Context, id int64, req *model.UpdateClassroomRequest) (*model.Classroom, error) { + // Build SET clause dynamically for partial updates + sets := []string{} + args := []interface{}{} + argIdx := 1 + + if req.Name != nil { + sets = append(sets, fmt.Sprintf("name = $%d", argIdx)) + args = append(args, *req.Name) + argIdx++ + } + if req.Description != nil { + sets = append(sets, fmt.Sprintf("description = $%d", argIdx)) + args = append(args, *req.Description) + argIdx++ + } + if req.Public != nil { + sets = append(sets, fmt.Sprintf("public = $%d", argIdx)) + args = append(args, *req.Public) + argIdx++ + } + + if len(sets) == 0 { + return r.GetByID(ctx, id) + } + + sets = append(sets, "updated_at = NOW()") + + query := fmt.Sprintf("UPDATE classrooms SET %s WHERE id = $%d RETURNING "+ + "id, name, slug, description, organization_name, organization_id, "+ + "instructor_id, instructor_login, public, archived, "+ + "created_at, updated_at, archived_at", + joinStrings(sets, ", "), argIdx) + args = append(args, id) + + c := &model.Classroom{} + err := r.db.QueryRowContext(ctx, query, args...).Scan( + &c.ID, &c.Name, &c.Slug, &c.Description, &c.OrganizationName, + &c.OrganizationID, &c.InstructorID, &c.InstructorLogin, &c.Public, + &c.Archived, &c.CreatedAt, &c.UpdatedAt, &c.ArchivedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("update classroom: %w", err) + } + return c, nil +} + +func (r *classroomRepo) Delete(ctx context.Context, id int64) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM classrooms WHERE id = $1", id) + if err != nil { + return fmt.Errorf("delete classroom: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("delete classroom: not found") + } + return nil +} + +func (r *classroomRepo) Archive(ctx context.Context, id int64) (*model.Classroom, error) { + now := time.Now().UTC() + query := `UPDATE classrooms SET archived = true, archived_at = $1, updated_at = $1 + WHERE id = $2 RETURNING id, name, slug, description, organization_name, + organization_id, instructor_id, instructor_login, public, archived, + created_at, updated_at, archived_at` + + c := &model.Classroom{} + err := r.db.QueryRowContext(ctx, query, now, id).Scan( + &c.ID, &c.Name, &c.Slug, &c.Description, &c.OrganizationName, + &c.OrganizationID, &c.InstructorID, &c.InstructorLogin, &c.Public, + &c.Archived, &c.CreatedAt, &c.UpdatedAt, &c.ArchivedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("archive classroom: %w", err) + } + return c, nil +} + +func (r *classroomRepo) GetStats(ctx context.Context, id int64) (*model.ClassroomStats, error) { + query := ` + SELECT + $1::bigint AS classroom_id, + COALESCE((SELECT COUNT(*) FROM roster_entries WHERE classroom_id = $1), 0) AS total_students, + COALESCE((SELECT COUNT(*) FROM roster_entries WHERE classroom_id = $1 AND platform_user_id IS NOT NULL), 0) AS linked_students, + COALESCE((SELECT COUNT(*) FROM assignments WHERE classroom_id = $1), 0) AS total_assignments, + COALESCE((SELECT COUNT(*) FROM assignments WHERE classroom_id = $1 AND (deadline IS NULL OR deadline > NOW())), 0) AS active_assignments, + COALESCE((SELECT COUNT(*) FROM submissions s JOIN assignments a ON s.assignment_id = a.id WHERE a.classroom_id = $1), 0) AS total_submissions` + + s := &model.ClassroomStats{} + err := r.db.QueryRowContext(ctx, query, id).Scan( + &s.ClassroomID, &s.TotalStudents, &s.LinkedStudents, + &s.TotalAssignments, &s.ActiveAssignments, &s.TotalSubmissions, + ) + if err != nil { + return nil, fmt.Errorf("get classroom stats: %w", err) + } + return s, nil +} + +// joinStrings joins a slice of strings with a separator. +func joinStrings(ss []string, sep string) string { + result := "" + for i, s := range ss { + if i > 0 { + result += sep + } + result += s + } + return result +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go new file mode 100644 index 0000000..86e033e --- /dev/null +++ b/internal/repository/repository.go @@ -0,0 +1,50 @@ +// Package repository provides data-access interfaces and their PostgreSQL +// implementations for every domain entity. +package repository + +import ( + "context" + "database/sql" +) + +// Pagination holds pagination parameters for list queries. +type Pagination struct { + Page int + PerPage int +} + +// Offset returns the SQL offset for the current page. +func (p Pagination) Offset() int { + if p.Page < 1 { + p.Page = 1 + } + return (p.Page - 1) * p.Limit() +} + +// Limit returns the SQL limit (clamped to 1-100, default 20). +func (p Pagination) Limit() int { + if p.PerPage < 1 { + return 20 + } + if p.PerPage > 100 { + return 100 + } + return p.PerPage +} + +// TotalPages returns the number of pages given a total item count. +func (p Pagination) TotalPages(total int) int { + limit := p.Limit() + if total <= 0 || limit <= 0 { + return 0 + } + return (total + limit - 1) / limit +} + +// DBTX is the common interface shared by *sql.DB and *sql.Tx so that +// repository methods can work inside or outside a transaction. +type DBTX interface { + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) +} diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go new file mode 100644 index 0000000..33712fd --- /dev/null +++ b/internal/repository/repository_test.go @@ -0,0 +1,70 @@ +package repository + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPagination_Offset(t *testing.T) { + tests := []struct { + name string + page int + perPage int + expected int + }{ + {"page 1", 1, 20, 0}, + {"page 2", 2, 20, 20}, + {"page 3 small", 3, 5, 10}, + {"zero page defaults to 1", 0, 20, 0}, + {"negative page defaults to 1", -1, 20, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pg := Pagination{Page: tt.page, PerPage: tt.perPage} + assert.Equal(t, tt.expected, pg.Offset()) + }) + } +} + +func TestPagination_Limit(t *testing.T) { + tests := []struct { + name string + perPage int + expected int + }{ + {"default when zero", 0, 20}, + {"default when negative", -1, 20}, + {"normal value", 50, 50}, + {"clamped to 100", 200, 100}, + {"exactly 100", 100, 100}, + {"exactly 1", 1, 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pg := Pagination{PerPage: tt.perPage} + assert.Equal(t, tt.expected, pg.Limit()) + }) + } +} + +func TestPagination_TotalPages(t *testing.T) { + tests := []struct { + name string + total int + perPage int + expected int + }{ + {"zero total", 0, 20, 0}, + {"exact division", 40, 20, 2}, + {"remainder", 41, 20, 3}, + {"one item", 1, 20, 1}, + {"negative total", -1, 20, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pg := Pagination{PerPage: tt.perPage} + assert.Equal(t, tt.expected, pg.TotalPages(tt.total)) + }) + } +} diff --git a/internal/repository/roster.go b/internal/repository/roster.go new file mode 100644 index 0000000..bd48303 --- /dev/null +++ b/internal/repository/roster.go @@ -0,0 +1,192 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + "code.forgejo.org/forgejo/classroom/internal/model" +) + +// RosterRepository defines data-access operations for roster entries. +type RosterRepository interface { + Create(ctx context.Context, e *model.RosterEntry) (*model.RosterEntry, error) + GetByID(ctx context.Context, id int64) (*model.RosterEntry, error) + GetByClassroomAndStudentID(ctx context.Context, classroomID int64, studentID string) (*model.RosterEntry, error) + List(ctx context.Context, filter RosterFilter, pg Pagination) ([]model.RosterEntry, int, error) + Delete(ctx context.Context, id int64) error + LinkStudent(ctx context.Context, id int64, platformUsername string, platformUserID int64) (*model.RosterEntry, error) + UnlinkStudent(ctx context.Context, id int64) (*model.RosterEntry, error) + CountByClassroom(ctx context.Context, classroomID int64) (int, error) +} + +// RosterFilter holds optional filters for listing roster entries. +type RosterFilter struct { + ClassroomID int64 + LinkedOnly bool + UnlinkedOnly bool +} + +type rosterRepo struct { + db DBTX +} + +// NewRosterRepository creates a new PostgreSQL-backed RosterRepository. +func NewRosterRepository(db DBTX) RosterRepository { + return &rosterRepo{db: db} +} + +const rosterColumns = `id, classroom_id, student_name, student_email, student_id, + platform_username, platform_user_id, role, linked_at, created_at, updated_at` + +func scanRoster(row interface{ Scan(...interface{}) error }, e *model.RosterEntry) error { + return row.Scan( + &e.ID, &e.ClassroomID, &e.StudentName, &e.StudentEmail, &e.StudentID, + &e.PlatformUsername, &e.PlatformUserID, &e.Role, &e.LinkedAt, + &e.CreatedAt, &e.UpdatedAt, + ) +} + +func (r *rosterRepo) Create(ctx context.Context, e *model.RosterEntry) (*model.RosterEntry, error) { + if e.Role == "" { + e.Role = "student" + } + query := ` + INSERT INTO roster_entries (classroom_id, student_name, student_email, student_id, role) + VALUES ($1, $2, $3, $4, $5) + RETURNING ` + rosterColumns + + err := scanRoster(r.db.QueryRowContext(ctx, query, + e.ClassroomID, e.StudentName, e.StudentEmail, e.StudentID, e.Role, + ), e) + if err != nil { + return nil, fmt.Errorf("create roster entry: %w", err) + } + return e, nil +} + +func (r *rosterRepo) GetByID(ctx context.Context, id int64) (*model.RosterEntry, error) { + query := "SELECT " + rosterColumns + " FROM roster_entries WHERE id = $1" + e := &model.RosterEntry{} + err := scanRoster(r.db.QueryRowContext(ctx, query, id), e) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get roster entry by id: %w", err) + } + return e, nil +} + +func (r *rosterRepo) GetByClassroomAndStudentID(ctx context.Context, classroomID int64, studentID string) (*model.RosterEntry, error) { + query := "SELECT " + rosterColumns + " FROM roster_entries WHERE classroom_id = $1 AND student_id = $2" + e := &model.RosterEntry{} + err := scanRoster(r.db.QueryRowContext(ctx, query, classroomID, studentID), e) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get roster entry: %w", err) + } + return e, nil +} + +func (r *rosterRepo) List(ctx context.Context, filter RosterFilter, pg Pagination) ([]model.RosterEntry, int, error) { + where := "WHERE 1=1" + args := []interface{}{} + argIdx := 1 + + if filter.ClassroomID > 0 { + where += fmt.Sprintf(" AND classroom_id = $%d", argIdx) + args = append(args, filter.ClassroomID) + argIdx++ + } + if filter.LinkedOnly { + where += " AND platform_user_id IS NOT NULL" + } + if filter.UnlinkedOnly { + where += " AND platform_user_id IS NULL" + } + + var total int + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM roster_entries "+where, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count roster entries: %w", err) + } + + dataQuery := fmt.Sprintf("SELECT %s FROM roster_entries %s ORDER BY student_name ASC LIMIT $%d OFFSET $%d", + rosterColumns, where, argIdx, argIdx+1) + args = append(args, pg.Limit(), pg.Offset()) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("list roster entries: %w", err) + } + defer rows.Close() + + var entries []model.RosterEntry + for rows.Next() { + var e model.RosterEntry + if err := scanRoster(rows, &e); err != nil { + return nil, 0, fmt.Errorf("scan roster entry: %w", err) + } + entries = append(entries, e) + } + return entries, total, rows.Err() +} + +func (r *rosterRepo) Delete(ctx context.Context, id int64) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM roster_entries WHERE id = $1", id) + if err != nil { + return fmt.Errorf("delete roster entry: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("delete roster entry: not found") + } + return nil +} + +func (r *rosterRepo) LinkStudent(ctx context.Context, id int64, platformUsername string, platformUserID int64) (*model.RosterEntry, error) { + now := time.Now().UTC() + query := `UPDATE roster_entries + SET platform_username = $1, platform_user_id = $2, linked_at = $3, updated_at = $3 + WHERE id = $4 + RETURNING ` + rosterColumns + + e := &model.RosterEntry{} + err := scanRoster(r.db.QueryRowContext(ctx, query, platformUsername, platformUserID, now, id), e) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("link student: %w", err) + } + return e, nil +} + +func (r *rosterRepo) UnlinkStudent(ctx context.Context, id int64) (*model.RosterEntry, error) { + query := `UPDATE roster_entries + SET platform_username = NULL, platform_user_id = NULL, linked_at = NULL, updated_at = NOW() + WHERE id = $1 + RETURNING ` + rosterColumns + + e := &model.RosterEntry{} + err := scanRoster(r.db.QueryRowContext(ctx, query, id), e) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("unlink student: %w", err) + } + return e, nil +} + +func (r *rosterRepo) CountByClassroom(ctx context.Context, classroomID int64) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM roster_entries WHERE classroom_id = $1", classroomID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count roster entries: %w", err) + } + return count, nil +} diff --git a/internal/repository/submission.go b/internal/repository/submission.go new file mode 100644 index 0000000..69b343d --- /dev/null +++ b/internal/repository/submission.go @@ -0,0 +1,190 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "code.forgejo.org/forgejo/classroom/internal/model" +) + +// SubmissionRepository defines data-access operations for submissions. +type SubmissionRepository interface { + Create(ctx context.Context, s *model.Submission) (*model.Submission, error) + GetByID(ctx context.Context, id int64) (*model.Submission, error) + GetByAssignmentAndStudent(ctx context.Context, assignmentID, studentID int64) (*model.Submission, error) + GetByAssignmentAndTeam(ctx context.Context, assignmentID, teamID int64) (*model.Submission, error) + List(ctx context.Context, filter SubmissionFilter, pg Pagination) ([]model.Submission, int, error) + UpdateStatus(ctx context.Context, id int64, status string) error + UpdateCommitInfo(ctx context.Context, id int64, sha, message string, count int) error +} + +// SubmissionFilter holds optional filters for listing submissions. +type SubmissionFilter struct { + AssignmentID *int64 + StudentID *int64 + ClassroomID *int64 + Status string + TeamOnly bool + IndividualOnly bool +} + +type submissionRepo struct { + db DBTX +} + +// NewSubmissionRepository creates a new PostgreSQL-backed SubmissionRepository. +func NewSubmissionRepository(db DBTX) SubmissionRepository { + return &submissionRepo{db: db} +} + +const submissionColumns = `id, assignment_id, student_id, team_id, + repository_name, repository_id, repository_url, status, accepted_at, + last_commit_sha, last_commit_message, commit_count, created_at, updated_at` + +func scanSubmission(row interface{ Scan(...interface{}) error }, s *model.Submission) error { + return row.Scan( + &s.ID, &s.AssignmentID, &s.StudentID, &s.TeamID, + &s.RepositoryName, &s.RepositoryID, &s.RepositoryURL, &s.Status, + &s.AcceptedAt, &s.LastCommitSHA, &s.LastCommitMessage, &s.CommitCount, + &s.CreatedAt, &s.UpdatedAt, + ) +} + +func (r *submissionRepo) Create(ctx context.Context, s *model.Submission) (*model.Submission, error) { + query := ` + INSERT INTO submissions (assignment_id, student_id, team_id, + repository_name, repository_id, repository_url, status, accepted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING ` + submissionColumns + + err := scanSubmission(r.db.QueryRowContext(ctx, query, + s.AssignmentID, s.StudentID, s.TeamID, + s.RepositoryName, s.RepositoryID, s.RepositoryURL, s.Status, s.AcceptedAt, + ), s) + if err != nil { + return nil, fmt.Errorf("create submission: %w", err) + } + return s, nil +} + +func (r *submissionRepo) GetByID(ctx context.Context, id int64) (*model.Submission, error) { + query := "SELECT " + submissionColumns + " FROM submissions WHERE id = $1" + s := &model.Submission{} + err := scanSubmission(r.db.QueryRowContext(ctx, query, id), s) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get submission by id: %w", err) + } + return s, nil +} + +func (r *submissionRepo) GetByAssignmentAndStudent(ctx context.Context, assignmentID, studentID int64) (*model.Submission, error) { + query := "SELECT " + submissionColumns + " FROM submissions WHERE assignment_id = $1 AND student_id = $2" + s := &model.Submission{} + err := scanSubmission(r.db.QueryRowContext(ctx, query, assignmentID, studentID), s) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get submission by assignment and student: %w", err) + } + return s, nil +} + +func (r *submissionRepo) GetByAssignmentAndTeam(ctx context.Context, assignmentID, teamID int64) (*model.Submission, error) { + query := "SELECT " + submissionColumns + " FROM submissions WHERE assignment_id = $1 AND team_id = $2" + s := &model.Submission{} + err := scanSubmission(r.db.QueryRowContext(ctx, query, assignmentID, teamID), s) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get submission by assignment and team: %w", err) + } + return s, nil +} + +func (r *submissionRepo) List(ctx context.Context, filter SubmissionFilter, pg Pagination) ([]model.Submission, int, error) { + where := "WHERE 1=1" + args := []interface{}{} + argIdx := 1 + + if filter.AssignmentID != nil { + where += fmt.Sprintf(" AND s.assignment_id = $%d", argIdx) + args = append(args, *filter.AssignmentID) + argIdx++ + } + if filter.StudentID != nil { + where += fmt.Sprintf(" AND s.student_id = $%d", argIdx) + args = append(args, *filter.StudentID) + argIdx++ + } + if filter.ClassroomID != nil { + where += fmt.Sprintf(" AND a.classroom_id = $%d", argIdx) + args = append(args, *filter.ClassroomID) + argIdx++ + } + if filter.Status != "" { + where += fmt.Sprintf(" AND s.status = $%d", argIdx) + args = append(args, filter.Status) + argIdx++ + } + if filter.TeamOnly { + where += " AND s.team_id IS NOT NULL" + } + if filter.IndividualOnly { + where += " AND s.student_id IS NOT NULL AND s.team_id IS NULL" + } + + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM submissions s JOIN assignments a ON s.assignment_id = a.id %s", where) + var total int + if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count submissions: %w", err) + } + + dataQuery := fmt.Sprintf(`SELECT s.%s FROM submissions s + JOIN assignments a ON s.assignment_id = a.id + %s ORDER BY s.created_at DESC LIMIT $%d OFFSET $%d`, + submissionColumns, where, argIdx, argIdx+1) + args = append(args, pg.Limit(), pg.Offset()) + + rows, err := r.db.QueryContext(ctx, dataQuery, args...) + if err != nil { + return nil, 0, fmt.Errorf("list submissions: %w", err) + } + defer rows.Close() + + var submissions []model.Submission + for rows.Next() { + var s model.Submission + if err := scanSubmission(rows, &s); err != nil { + return nil, 0, fmt.Errorf("scan submission: %w", err) + } + submissions = append(submissions, s) + } + return submissions, total, rows.Err() +} + +func (r *submissionRepo) UpdateStatus(ctx context.Context, id int64, status string) error { + _, err := r.db.ExecContext(ctx, + "UPDATE submissions SET status = $1, updated_at = NOW() WHERE id = $2", + status, id) + if err != nil { + return fmt.Errorf("update submission status: %w", err) + } + return nil +} + +func (r *submissionRepo) UpdateCommitInfo(ctx context.Context, id int64, sha, message string, count int) error { + _, err := r.db.ExecContext(ctx, + `UPDATE submissions SET last_commit_sha = $1, last_commit_message = $2, + commit_count = $3, updated_at = NOW() WHERE id = $4`, + sha, message, count, id) + if err != nil { + return fmt.Errorf("update submission commit info: %w", err) + } + return nil +} diff --git a/internal/repository/team.go b/internal/repository/team.go new file mode 100644 index 0000000..ed11c38 --- /dev/null +++ b/internal/repository/team.go @@ -0,0 +1,216 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "code.forgejo.org/forgejo/classroom/internal/model" +) + +// TeamRepository defines data-access operations for teams and their members. +type TeamRepository interface { + Create(ctx context.Context, t *model.Team) (*model.Team, error) + GetByID(ctx context.Context, id int64) (*model.Team, error) + GetByAssignmentAndSlug(ctx context.Context, assignmentID int64, slug string) (*model.Team, error) + ListByAssignment(ctx context.Context, assignmentID int64, pg Pagination) ([]model.Team, int, error) + Delete(ctx context.Context, id int64) error + + // Member operations + AddMember(ctx context.Context, teamID, studentID int64, role string) error + RemoveMember(ctx context.Context, teamID, studentID int64) error + ListMembers(ctx context.Context, teamID int64) ([]model.TeamMember, error) + IsMember(ctx context.Context, teamID, studentID int64) (bool, error) + GetWithMembers(ctx context.Context, teamID int64) (*model.TeamWithMembers, error) +} + +type teamRepo struct { + db DBTX +} + +// NewTeamRepository creates a new PostgreSQL-backed TeamRepository. +func NewTeamRepository(db DBTX) TeamRepository { + return &teamRepo{db: db} +} + +const teamColumns = `id, assignment_id, name, slug, description, leader_id, member_count, created_at, updated_at` + +func scanTeam(row interface{ Scan(...interface{}) error }, t *model.Team) error { + return row.Scan( + &t.ID, &t.AssignmentID, &t.Name, &t.Slug, &t.Description, + &t.LeaderID, &t.MemberCount, &t.CreatedAt, &t.UpdatedAt, + ) +} + +func (r *teamRepo) Create(ctx context.Context, t *model.Team) (*model.Team, error) { + query := ` + INSERT INTO teams (assignment_id, name, slug, description, leader_id, member_count) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ` + teamColumns + + err := scanTeam(r.db.QueryRowContext(ctx, query, + t.AssignmentID, t.Name, t.Slug, t.Description, t.LeaderID, t.MemberCount, + ), t) + if err != nil { + return nil, fmt.Errorf("create team: %w", err) + } + return t, nil +} + +func (r *teamRepo) GetByID(ctx context.Context, id int64) (*model.Team, error) { + query := "SELECT " + teamColumns + " FROM teams WHERE id = $1" + t := &model.Team{} + err := scanTeam(r.db.QueryRowContext(ctx, query, id), t) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get team by id: %w", err) + } + return t, nil +} + +func (r *teamRepo) GetByAssignmentAndSlug(ctx context.Context, assignmentID int64, slug string) (*model.Team, error) { + query := "SELECT " + teamColumns + " FROM teams WHERE assignment_id = $1 AND slug = $2" + t := &model.Team{} + err := scanTeam(r.db.QueryRowContext(ctx, query, assignmentID, slug), t) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get team by assignment and slug: %w", err) + } + return t, nil +} + +func (r *teamRepo) ListByAssignment(ctx context.Context, assignmentID int64, pg Pagination) ([]model.Team, int, error) { + var total int + if err := r.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM teams WHERE assignment_id = $1", assignmentID, + ).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count teams: %w", err) + } + + query := fmt.Sprintf("SELECT %s FROM teams WHERE assignment_id = $1 ORDER BY name ASC LIMIT $2 OFFSET $3", teamColumns) + rows, err := r.db.QueryContext(ctx, query, assignmentID, pg.Limit(), pg.Offset()) + if err != nil { + return nil, 0, fmt.Errorf("list teams: %w", err) + } + defer rows.Close() + + var teams []model.Team + for rows.Next() { + var t model.Team + if err := scanTeam(rows, &t); err != nil { + return nil, 0, fmt.Errorf("scan team: %w", err) + } + teams = append(teams, t) + } + return teams, total, rows.Err() +} + +func (r *teamRepo) Delete(ctx context.Context, id int64) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM teams WHERE id = $1", id) + if err != nil { + return fmt.Errorf("delete team: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("delete team: not found") + } + return nil +} + +func (r *teamRepo) AddMember(ctx context.Context, teamID, studentID int64, role string) error { + if role == "" { + role = "member" + } + _, err := r.db.ExecContext(ctx, + "INSERT INTO team_members (team_id, student_id, role) VALUES ($1, $2, $3)", + teamID, studentID, role) + if err != nil { + return fmt.Errorf("add team member: %w", err) + } + // Increment cached member_count + _, err = r.db.ExecContext(ctx, + "UPDATE teams SET member_count = member_count + 1, updated_at = NOW() WHERE id = $1", teamID) + if err != nil { + return fmt.Errorf("update team member count: %w", err) + } + return nil +} + +func (r *teamRepo) RemoveMember(ctx context.Context, teamID, studentID int64) error { + res, err := r.db.ExecContext(ctx, + "DELETE FROM team_members WHERE team_id = $1 AND student_id = $2", + teamID, studentID) + if err != nil { + return fmt.Errorf("remove team member: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("remove team member: not found") + } + _, err = r.db.ExecContext(ctx, + "UPDATE teams SET member_count = GREATEST(member_count - 1, 0), updated_at = NOW() WHERE id = $1", teamID) + if err != nil { + return fmt.Errorf("update team member count: %w", err) + } + return nil +} + +func (r *teamRepo) ListMembers(ctx context.Context, teamID int64) ([]model.TeamMember, error) { + rows, err := r.db.QueryContext(ctx, + "SELECT id, team_id, student_id, role, joined_at FROM team_members WHERE team_id = $1 ORDER BY joined_at ASC", + teamID) + if err != nil { + return nil, fmt.Errorf("list team members: %w", err) + } + defer rows.Close() + + var members []model.TeamMember + for rows.Next() { + var m model.TeamMember + if err := rows.Scan(&m.ID, &m.TeamID, &m.StudentID, &m.Role, &m.JoinedAt); err != nil { + return nil, fmt.Errorf("scan team member: %w", err) + } + members = append(members, m) + } + return members, rows.Err() +} + +func (r *teamRepo) IsMember(ctx context.Context, teamID, studentID int64) (bool, error) { + var exists bool + err := r.db.QueryRowContext(ctx, + "SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND student_id = $2)", + teamID, studentID).Scan(&exists) + if err != nil { + return false, fmt.Errorf("check team membership: %w", err) + } + return exists, nil +} + +func (r *teamRepo) GetWithMembers(ctx context.Context, teamID int64) (*model.TeamWithMembers, error) { + t, err := r.GetByID(ctx, teamID) + if err != nil || t == nil { + return nil, err + } + + members, err := r.ListMembers(ctx, teamID) + if err != nil { + return nil, err + } + + return &model.TeamWithMembers{ + Team: *t, + Members: toMemberInfo(members), + }, nil +} + +func toMemberInfo(members []model.TeamMember) []model.TeamMemberInfo { + infos := make([]model.TeamMemberInfo, len(members)) + for i, m := range members { + infos[i] = model.TeamMemberInfo{TeamMember: m} + } + return infos +} diff --git a/internal/response/response.go b/internal/response/response.go index 90f17b8..689533d 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -106,6 +106,6 @@ func getRequestID(c *gin.Context) string { func getDocumentationURL(errorCode string) string { // TODO: Return documentation URLs for error codes - baseURL := "https://docs.forgejo-classroom.org/errors/" + baseURL := "https://docs.class-forge.org/errors/" return baseURL + errorCode } diff --git a/internal/service/assignment.go b/internal/service/assignment.go new file mode 100644 index 0000000..bf7bff3 --- /dev/null +++ b/internal/service/assignment.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" + "code.forgejo.org/forgejo/classroom/internal/util" +) + +// AssignmentService handles assignment business logic. +type AssignmentService struct { + repo repository.AssignmentRepository + classroomRepo repository.ClassroomRepository + provider platform.Provider + db *database.DB + logger *zap.Logger +} + +// NewAssignmentService creates a new AssignmentService. +func NewAssignmentService( + repo repository.AssignmentRepository, + classroomRepo repository.ClassroomRepository, + provider platform.Provider, + db *database.DB, + logger *zap.Logger, +) *AssignmentService { + return &AssignmentService{ + repo: repo, classroomRepo: classroomRepo, + provider: provider, db: db, logger: logger, + } +} + +// Create validates the request and persists a new assignment. +func (s *AssignmentService) Create(ctx context.Context, req *model.CreateAssignmentRequest) (*model.Assignment, error) { + v := util.NewValidator() + v.ValidateRequired("name", req.Name, "Name") + v.ValidateLength("name", req.Name, "Name", 1, 255) + v.ValidateRequired("template_repository", req.TemplateRepository, "Template repository") + if req.Deadline != "" { + v.ValidateDateTime("deadline", req.Deadline, "Deadline") + v.ValidateFutureDate("deadline", req.Deadline, "Deadline") + } + if req.MaxTeamSize > 0 { + v.ValidateRange("max_team_size", req.MaxTeamSize, 1, 100, "Max team size") + } + if v.HasErrors() { + return nil, v.Errors() + } + + // Verify classroom exists + classroom, err := s.classroomRepo.GetByID(ctx, req.ClassroomID) + if err != nil { + return nil, err + } + if classroom == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom %d not found", req.ClassroomID) + } + + slug := util.GenerateSlug(req.Name) + + // Check slug uniqueness within classroom + existing, err := s.repo.GetByClassroomAndSlug(ctx, req.ClassroomID, slug) + if err != nil { + return nil, err + } + if existing != nil { + return nil, fmt.Errorf("RESOURCE_ALREADY_EXISTS: assignment with slug %q already exists in this classroom", slug) + } + + // Verify template repository exists on the platform + ownerRepo := splitOwnerRepo(req.TemplateRepository) + repo, err := s.provider.GetRepository(ctx, ownerRepo[0], ownerRepo[1]) + if err != nil { + return nil, fmt.Errorf("BUSINESS_TEMPLATE_NOT_FOUND: %w", err) + } + + var deadline *time.Time + if req.Deadline != "" { + t, _ := time.Parse(time.RFC3339, req.Deadline) + deadline = &t + } + + maxTeamSize := req.MaxTeamSize + if maxTeamSize == 0 { + maxTeamSize = 1 + } + + assignment := &model.Assignment{ + ClassroomID: req.ClassroomID, + Name: req.Name, + Slug: slug, + Description: req.Description, + TemplateRepository: req.TemplateRepository, + TemplateRepositoryID: repo.ID, + Deadline: deadline, + MaxTeamSize: maxTeamSize, + AutoAccept: req.AutoAccept, + Public: req.Public, + } + + result, err := s.repo.Create(ctx, assignment) + if err != nil { + return nil, fmt.Errorf("create assignment: %w", err) + } + + s.logger.Info("assignment created", + zap.Int64("id", result.ID), + zap.String("slug", result.Slug), + zap.Int64("classroom_id", result.ClassroomID), + ) + return result, nil +} + +// GetByID returns an assignment by its ID. +func (s *AssignmentService) GetByID(ctx context.Context, id int64) (*model.Assignment, error) { + a, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if a == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: assignment %d not found", id) + } + return a, nil +} + +// List returns a paginated list of assignments with optional filters. +func (s *AssignmentService) List(ctx context.Context, req *model.AssignmentListRequest) (*model.AssignmentListResponse, error) { + filter := repository.AssignmentFilter{ + ClassroomID: req.ClassroomID, + Status: req.Status, + } + pg := repository.Pagination{Page: req.Page, PerPage: req.PerPage} + + assignments, total, err := s.repo.List(ctx, filter, pg) + if err != nil { + return nil, err + } + + return &model.AssignmentListResponse{ + Assignments: assignments, + Total: total, + Page: pg.Page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + }, nil +} + +// Update applies partial updates to an assignment. +func (s *AssignmentService) Update(ctx context.Context, id int64, req *model.UpdateAssignmentRequest) (*model.Assignment, error) { + if req.Name != nil { + v := util.NewValidator() + v.ValidateLength("name", *req.Name, "Name", 1, 255) + if v.HasErrors() { + return nil, v.Errors() + } + } + + a, err := s.repo.Update(ctx, id, req) + if err != nil { + return nil, err + } + if a == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: assignment %d not found", id) + } + return a, nil +} + +// Delete permanently removes an assignment. +func (s *AssignmentService) Delete(ctx context.Context, id int64) error { + return s.repo.Delete(ctx, id) +} + +// GetStats returns aggregate statistics for an assignment. +func (s *AssignmentService) GetStats(ctx context.Context, id int64) (*model.AssignmentStats, error) { + if _, err := s.GetByID(ctx, id); err != nil { + return nil, err + } + return s.repo.GetStats(ctx, id) +} + +// splitOwnerRepo splits "owner/repo" into [owner, repo]. +// Falls back to ["", input] if no slash is found. +func splitOwnerRepo(fullName string) [2]string { + for i := 0; i < len(fullName); i++ { + if fullName[i] == '/' { + return [2]string{fullName[:i], fullName[i+1:]} + } + } + return [2]string{"", fullName} +} diff --git a/internal/service/assignment_test.go b/internal/service/assignment_test.go new file mode 100644 index 0000000..e165857 --- /dev/null +++ b/internal/service/assignment_test.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/repository" +) + +func TestAssignmentService_Create_Success(t *testing.T) { + classroomRepo := newMockClassroomRepo() + assignmentRepo := newMockAssignmentRepo() + provider := newMockProvider() + + // Seed a classroom + classroomRepo.classrooms[1] = &model.Classroom{ID: 1, Name: "CS101", Slug: "cs101"} + + svc := NewAssignmentService(assignmentRepo, classroomRepo, provider, nil, testLogger()) + + req := &model.CreateAssignmentRequest{ + ClassroomID: 1, + Name: "Homework 1", + TemplateRepository: "org/hw1-starter", + } + + a, err := svc.Create(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, "Homework 1", a.Name) + assert.Equal(t, "homework-1", a.Slug) + assert.Equal(t, int64(1), a.ClassroomID) + assert.Equal(t, 1, a.MaxTeamSize) // default +} + +func TestAssignmentService_Create_ClassroomNotFound(t *testing.T) { + classroomRepo := newMockClassroomRepo() + assignmentRepo := newMockAssignmentRepo() + provider := newMockProvider() + + svc := NewAssignmentService(assignmentRepo, classroomRepo, provider, nil, testLogger()) + + req := &model.CreateAssignmentRequest{ + ClassroomID: 999, + Name: "Homework 1", + TemplateRepository: "org/hw1-starter", + } + + _, err := svc.Create(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_NOT_FOUND") +} + +func TestAssignmentService_Create_MissingName(t *testing.T) { + classroomRepo := newMockClassroomRepo() + assignmentRepo := newMockAssignmentRepo() + provider := newMockProvider() + + classroomRepo.classrooms[1] = &model.Classroom{ID: 1} + + svc := NewAssignmentService(assignmentRepo, classroomRepo, provider, nil, testLogger()) + + req := &model.CreateAssignmentRequest{ + ClassroomID: 1, + TemplateRepository: "org/hw1-starter", + } + + _, err := svc.Create(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") +} + +func TestAssignmentService_Create_DuplicateSlug(t *testing.T) { + classroomRepo := newMockClassroomRepo() + assignmentRepo := newMockAssignmentRepo() + provider := newMockProvider() + + classroomRepo.classrooms[1] = &model.Classroom{ID: 1} + + svc := NewAssignmentService(assignmentRepo, classroomRepo, provider, nil, testLogger()) + + req := &model.CreateAssignmentRequest{ + ClassroomID: 1, + Name: "Homework 1", + TemplateRepository: "org/hw1-starter", + } + + _, err := svc.Create(context.Background(), req) + require.NoError(t, err) + + _, err = svc.Create(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_ALREADY_EXISTS") +} + +func TestAssignmentService_GetByID_NotFound(t *testing.T) { + classroomRepo := newMockClassroomRepo() + assignmentRepo := newMockAssignmentRepo() + provider := newMockProvider() + + svc := NewAssignmentService(assignmentRepo, classroomRepo, provider, nil, testLogger()) + + _, err := svc.GetByID(context.Background(), 999) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_NOT_FOUND") +} + +func TestSplitOwnerRepo(t *testing.T) { + tests := []struct { + input string + expected [2]string + }{ + {"owner/repo", [2]string{"owner", "repo"}}, + {"org/template-repo", [2]string{"org", "template-repo"}}, + {"noslash", [2]string{"", "noslash"}}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, splitOwnerRepo(tt.input)) + }) + } +} + +// --- Mock AssignmentRepository --- + +type mockAssignmentRepo struct { + assignments map[int64]*model.Assignment + byClassroomSlug map[string]*model.Assignment + nextID int64 +} + +func newMockAssignmentRepo() *mockAssignmentRepo { + return &mockAssignmentRepo{ + assignments: make(map[int64]*model.Assignment), + byClassroomSlug: make(map[string]*model.Assignment), + nextID: 1, + } +} + +func classroomSlugKey(classroomID int64, slug string) string { + return fmt.Sprintf("%d:%s", classroomID, slug) +} + +func (m *mockAssignmentRepo) Create(_ context.Context, a *model.Assignment) (*model.Assignment, error) { + a.ID = m.nextID + m.nextID++ + m.assignments[a.ID] = a + m.byClassroomSlug[classroomSlugKey(a.ClassroomID, a.Slug)] = a + return a, nil +} + +func (m *mockAssignmentRepo) GetByID(_ context.Context, id int64) (*model.Assignment, error) { + return m.assignments[id], nil +} + +func (m *mockAssignmentRepo) GetByClassroomAndSlug(_ context.Context, classroomID int64, slug string) (*model.Assignment, error) { + return m.byClassroomSlug[classroomSlugKey(classroomID, slug)], nil +} + +func (m *mockAssignmentRepo) List(_ context.Context, _ repository.AssignmentFilter, _ repository.Pagination) ([]model.Assignment, int, error) { + var result []model.Assignment + for _, a := range m.assignments { + result = append(result, *a) + } + return result, len(result), nil +} + +func (m *mockAssignmentRepo) Update(_ context.Context, id int64, _ *model.UpdateAssignmentRequest) (*model.Assignment, error) { + return m.assignments[id], nil +} + +func (m *mockAssignmentRepo) Delete(_ context.Context, id int64) error { + delete(m.assignments, id) + return nil +} + +func (m *mockAssignmentRepo) GetStats(_ context.Context, id int64) (*model.AssignmentStats, error) { + return &model.AssignmentStats{AssignmentID: id}, nil +} diff --git a/internal/service/classroom.go b/internal/service/classroom.go new file mode 100644 index 0000000..c2fd774 --- /dev/null +++ b/internal/service/classroom.go @@ -0,0 +1,174 @@ +package service + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" + "code.forgejo.org/forgejo/classroom/internal/util" +) + +// ClassroomService handles classroom business logic. +type ClassroomService struct { + repo repository.ClassroomRepository + provider platform.Provider + db *database.DB + logger *zap.Logger +} + +// NewClassroomService creates a new ClassroomService. +func NewClassroomService( + repo repository.ClassroomRepository, + provider platform.Provider, + db *database.DB, + logger *zap.Logger, +) *ClassroomService { + return &ClassroomService{repo: repo, provider: provider, db: db, logger: logger} +} + +// Create validates the request, generates a slug, and persists the classroom. +func (s *ClassroomService) Create(ctx context.Context, req *model.CreateClassroomRequest, instructorID int64, instructorLogin string) (*model.Classroom, error) { + v := util.NewValidator() + v.ValidateRequired("name", req.Name, "Name") + v.ValidateLength("name", req.Name, "Name", 1, 255) + v.ValidateRequired("organization_name", req.OrganizationName, "Organization name") + if v.HasErrors() { + return nil, v.Errors() + } + + slug := util.GenerateSlug(req.Name) + + // Check slug uniqueness + existing, err := s.repo.GetBySlug(ctx, slug) + if err != nil { + return nil, fmt.Errorf("check slug: %w", err) + } + if existing != nil { + return nil, fmt.Errorf("RESOURCE_ALREADY_EXISTS: classroom with slug %q already exists", slug) + } + + // Verify the organization exists on the platform + org, err := s.provider.GetOrganization(ctx, req.OrganizationName) + if err != nil { + return nil, fmt.Errorf("INTEGRATION_PLATFORM_API_ERROR: %w", err) + } + + classroom := &model.Classroom{ + Name: req.Name, + Slug: slug, + Description: req.Description, + OrganizationName: req.OrganizationName, + OrganizationID: org.ID, + InstructorID: instructorID, + InstructorLogin: instructorLogin, + Public: req.Public, + } + + result, err := s.repo.Create(ctx, classroom) + if err != nil { + return nil, fmt.Errorf("create classroom: %w", err) + } + + s.logger.Info("classroom created", + zap.Int64("id", result.ID), + zap.String("slug", result.Slug), + ) + return result, nil +} + +// GetByID returns a classroom by its ID. +func (s *ClassroomService) GetByID(ctx context.Context, id int64) (*model.Classroom, error) { + c, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if c == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom %d not found", id) + } + return c, nil +} + +// GetBySlug returns a classroom by its slug. +func (s *ClassroomService) GetBySlug(ctx context.Context, slug string) (*model.Classroom, error) { + c, err := s.repo.GetBySlug(ctx, slug) + if err != nil { + return nil, err + } + if c == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom with slug %q not found", slug) + } + return c, nil +} + +// List returns a paginated list of classrooms with optional filters. +func (s *ClassroomService) List(ctx context.Context, req *model.ClassroomListRequest) (*model.ClassroomListResponse, error) { + filter := repository.ClassroomFilter{ + OrganizationName: req.OrganizationName, + IncludeArchived: req.IncludeArchived, + } + pg := repository.Pagination{Page: req.Page, PerPage: req.PerPage} + + classrooms, total, err := s.repo.List(ctx, filter, pg) + if err != nil { + return nil, err + } + + return &model.ClassroomListResponse{ + Classrooms: classrooms, + Total: total, + Page: pg.Page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + }, nil +} + +// Update applies partial updates to a classroom. +func (s *ClassroomService) Update(ctx context.Context, id int64, req *model.UpdateClassroomRequest) (*model.Classroom, error) { + if req.Name != nil { + v := util.NewValidator() + v.ValidateLength("name", *req.Name, "Name", 1, 255) + if v.HasErrors() { + return nil, v.Errors() + } + } + + c, err := s.repo.Update(ctx, id, req) + if err != nil { + return nil, err + } + if c == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom %d not found", id) + } + return c, nil +} + +// Delete permanently removes a classroom. +func (s *ClassroomService) Delete(ctx context.Context, id int64) error { + return s.repo.Delete(ctx, id) +} + +// Archive soft-deletes a classroom. +func (s *ClassroomService) Archive(ctx context.Context, id int64) (*model.Classroom, error) { + c, err := s.repo.Archive(ctx, id) + if err != nil { + return nil, err + } + if c == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom %d not found", id) + } + return c, nil +} + +// GetStats returns aggregate statistics for a classroom. +func (s *ClassroomService) GetStats(ctx context.Context, id int64) (*model.ClassroomStats, error) { + // Verify classroom exists + if _, err := s.GetByID(ctx, id); err != nil { + return nil, err + } + return s.repo.GetStats(ctx, id) +} diff --git a/internal/service/classroom_test.go b/internal/service/classroom_test.go new file mode 100644 index 0000000..a91ecfa --- /dev/null +++ b/internal/service/classroom_test.go @@ -0,0 +1,302 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" +) + +// --- Mock ClassroomRepository --- + +type mockClassroomRepo struct { + classrooms map[int64]*model.Classroom + bySlug map[string]*model.Classroom + nextID int64 +} + +func newMockClassroomRepo() *mockClassroomRepo { + return &mockClassroomRepo{ + classrooms: make(map[int64]*model.Classroom), + bySlug: make(map[string]*model.Classroom), + nextID: 1, + } +} + +func (m *mockClassroomRepo) Create(_ context.Context, c *model.Classroom) (*model.Classroom, error) { + c.ID = m.nextID + m.nextID++ + m.classrooms[c.ID] = c + m.bySlug[c.Slug] = c + return c, nil +} + +func (m *mockClassroomRepo) GetByID(_ context.Context, id int64) (*model.Classroom, error) { + c := m.classrooms[id] + return c, nil +} + +func (m *mockClassroomRepo) GetBySlug(_ context.Context, slug string) (*model.Classroom, error) { + c := m.bySlug[slug] + return c, nil +} + +func (m *mockClassroomRepo) List(_ context.Context, _ repository.ClassroomFilter, pg repository.Pagination) ([]model.Classroom, int, error) { + var result []model.Classroom + for _, c := range m.classrooms { + result = append(result, *c) + } + total := len(result) + end := pg.Offset() + pg.Limit() + if end > total { + end = total + } + start := pg.Offset() + if start > total { + start = total + } + return result[start:end], total, nil +} + +func (m *mockClassroomRepo) Update(_ context.Context, id int64, req *model.UpdateClassroomRequest) (*model.Classroom, error) { + c := m.classrooms[id] + if c == nil { + return nil, nil + } + if req.Name != nil { + c.Name = *req.Name + } + if req.Description != nil { + c.Description = *req.Description + } + if req.Public != nil { + c.Public = *req.Public + } + return c, nil +} + +func (m *mockClassroomRepo) Delete(_ context.Context, id int64) error { + c := m.classrooms[id] + if c == nil { + return fmt.Errorf("delete classroom: not found") + } + delete(m.bySlug, c.Slug) + delete(m.classrooms, id) + return nil +} + +func (m *mockClassroomRepo) Archive(_ context.Context, id int64) (*model.Classroom, error) { + c := m.classrooms[id] + if c == nil { + return nil, nil + } + c.Archived = true + return c, nil +} + +func (m *mockClassroomRepo) GetStats(_ context.Context, id int64) (*model.ClassroomStats, error) { + return &model.ClassroomStats{ClassroomID: id}, nil +} + +// --- Mock Platform Provider (minimal) --- + +type mockProvider struct { + orgs map[string]*platform.Organization +} + +func newMockProvider() *mockProvider { + return &mockProvider{ + orgs: map[string]*platform.Organization{ + "test-org": {ID: 100, Name: "test-org"}, + }, + } +} + +func (m *mockProvider) Type() platform.ProviderType { return "mock" } +func (m *mockProvider) GetRepository(_ context.Context, owner, name string) (*platform.Repository, error) { + return &platform.Repository{ID: 1, Name: name, FullName: owner + "/" + name}, nil +} +func (m *mockProvider) GetRepositoryByID(_ context.Context, id int64) (*platform.Repository, error) { + return &platform.Repository{ID: id}, nil +} +func (m *mockProvider) CreateRepositoryFromTemplate(_ context.Context, _ int64, opts platform.CreateRepoOptions) (*platform.Repository, error) { + return &platform.Repository{ID: 999, Name: opts.Name, FullName: opts.Owner + "/" + opts.Name, CloneURL: "https://example.com/" + opts.Name + ".git"}, nil +} +func (m *mockProvider) DeleteRepository(_ context.Context, _, _ string) error { return nil } +func (m *mockProvider) GetAuthenticatedUser(_ context.Context) (*platform.User, error) { + return &platform.User{ID: 1, Username: "admin"}, nil +} +func (m *mockProvider) GetUser(_ context.Context, username string) (*platform.User, error) { + return &platform.User{ID: 42, Username: username}, nil +} +func (m *mockProvider) GetUserByEmail(_ context.Context, email string) (*platform.User, error) { + return &platform.User{ID: 42, Email: email}, nil +} +func (m *mockProvider) GetOrganization(_ context.Context, name string) (*platform.Organization, error) { + org := m.orgs[name] + if org == nil { + return nil, fmt.Errorf("org not found") + } + return org, nil +} +func (m *mockProvider) CreateOrganization(_ context.Context, opts platform.CreateOrgOptions) (*platform.Organization, error) { + return &platform.Organization{ID: 200, Name: opts.Name}, nil +} +func (m *mockProvider) DeleteOrganization(_ context.Context, _ string) error { return nil } +func (m *mockProvider) CreateTeam(_ context.Context, _ platform.CreateTeamOptions) (*platform.Team, error) { + return &platform.Team{ID: 1}, nil +} +func (m *mockProvider) DeleteTeam(_ context.Context, _ int64) error { return nil } +func (m *mockProvider) AddTeamMember(_ context.Context, _, _ int64) error { return nil } +func (m *mockProvider) RemoveTeamMember(_ context.Context, _, _ int64) error { return nil } +func (m *mockProvider) AddTeamRepository(_ context.Context, _, _ int64) error { + return nil +} +func (m *mockProvider) RemoveTeamRepository(_ context.Context, _, _ int64) error { + return nil +} +func (m *mockProvider) ProtectBranch(_ context.Context, _ int64, _ platform.BranchProtection) error { + return nil +} +func (m *mockProvider) GetLatestCommit(_ context.Context, _, _, _ string) (*platform.Commit, error) { + return &platform.Commit{SHA: "abc123"}, nil +} + +// --- Tests --- + +func testLogger() *zap.Logger { + l, _ := zap.NewDevelopment() + return l +} + +func TestClassroomService_Create_Success(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + req := &model.CreateClassroomRequest{ + Name: "Intro to CS", + OrganizationName: "test-org", + Public: true, + } + + classroom, err := svc.Create(context.Background(), req, 1, "instructor") + require.NoError(t, err) + assert.Equal(t, "Intro to CS", classroom.Name) + assert.Equal(t, "intro-to-cs", classroom.Slug) + assert.Equal(t, int64(100), classroom.OrganizationID) + assert.True(t, classroom.Public) +} + +func TestClassroomService_Create_MissingName(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + req := &model.CreateClassroomRequest{ + OrganizationName: "test-org", + } + + _, err := svc.Create(context.Background(), req, 1, "instructor") + assert.Error(t, err) + assert.Contains(t, err.Error(), "Name is required") +} + +func TestClassroomService_Create_DuplicateSlug(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + req := &model.CreateClassroomRequest{ + Name: "Test Class", + OrganizationName: "test-org", + } + + _, err := svc.Create(context.Background(), req, 1, "instructor") + require.NoError(t, err) + + // Same name -> same slug -> conflict + _, err = svc.Create(context.Background(), req, 1, "instructor") + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_ALREADY_EXISTS") +} + +func TestClassroomService_Create_OrgNotFound(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + req := &model.CreateClassroomRequest{ + Name: "Test Class", + OrganizationName: "nonexistent-org", + } + + _, err := svc.Create(context.Background(), req, 1, "instructor") + assert.Error(t, err) + assert.Contains(t, err.Error(), "INTEGRATION_PLATFORM_API_ERROR") +} + +func TestClassroomService_GetByID_NotFound(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + _, err := svc.GetByID(context.Background(), 999) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_NOT_FOUND") +} + +func TestClassroomService_Update_NotFound(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + name := "New Name" + _, err := svc.Update(context.Background(), 999, &model.UpdateClassroomRequest{Name: &name}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RESOURCE_NOT_FOUND") +} + +func TestClassroomService_Archive(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + req := &model.CreateClassroomRequest{ + Name: "Archive Me", + OrganizationName: "test-org", + } + classroom, err := svc.Create(context.Background(), req, 1, "instructor") + require.NoError(t, err) + + archived, err := svc.Archive(context.Background(), classroom.ID) + require.NoError(t, err) + assert.True(t, archived.Archived) +} + +func TestClassroomService_List(t *testing.T) { + repo := newMockClassroomRepo() + provider := newMockProvider() + svc := NewClassroomService(repo, provider, nil, testLogger()) + + for i := 0; i < 3; i++ { + req := &model.CreateClassroomRequest{ + Name: fmt.Sprintf("Class %d", i), + OrganizationName: "test-org", + } + _, err := svc.Create(context.Background(), req, 1, "instructor") + require.NoError(t, err) + } + + result, err := svc.List(context.Background(), &model.ClassroomListRequest{Page: 1, PerPage: 10}) + require.NoError(t, err) + assert.Equal(t, 3, result.Total) + assert.Len(t, result.Classrooms, 3) +} diff --git a/internal/service/roster.go b/internal/service/roster.go new file mode 100644 index 0000000..f49d366 --- /dev/null +++ b/internal/service/roster.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" + "code.forgejo.org/forgejo/classroom/internal/util" +) + +// RosterService handles roster business logic. +type RosterService struct { + repo repository.RosterRepository + classroomRepo repository.ClassroomRepository + provider platform.Provider + logger *zap.Logger +} + +// NewRosterService creates a new RosterService. +func NewRosterService( + repo repository.RosterRepository, + classroomRepo repository.ClassroomRepository, + provider platform.Provider, + logger *zap.Logger, +) *RosterService { + return &RosterService{ + repo: repo, classroomRepo: classroomRepo, + provider: provider, logger: logger, + } +} + +// AddStudent validates and adds a student to a classroom roster. +func (s *RosterService) AddStudent(ctx context.Context, classroomID int64, req *model.AddStudentRequest) (*model.RosterEntry, error) { + v := util.NewValidator() + v.ValidateRequired("student_name", req.StudentName, "Student name") + v.ValidateRequired("student_email", req.StudentEmail, "Student email") + v.ValidateEmail("student_email", req.StudentEmail, "Student email") + v.ValidateRequired("student_id", req.StudentID, "Student ID") + if req.Role != "" { + v.ValidateEnum("role", req.Role, "Role", []string{"student", "assistant", "instructor"}) + } + if v.HasErrors() { + return nil, v.Errors() + } + + // Verify classroom exists + classroom, err := s.classroomRepo.GetByID(ctx, classroomID) + if err != nil { + return nil, err + } + if classroom == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: classroom %d not found", classroomID) + } + + // Check for duplicate student_id in this classroom + existing, err := s.repo.GetByClassroomAndStudentID(ctx, classroomID, req.StudentID) + if err != nil { + return nil, err + } + if existing != nil { + return nil, fmt.Errorf("RESOURCE_ALREADY_EXISTS: student %q already in this classroom roster", req.StudentID) + } + + role := req.Role + if role == "" { + role = "student" + } + + entry := &model.RosterEntry{ + ClassroomID: classroomID, + StudentName: req.StudentName, + StudentEmail: req.StudentEmail, + StudentID: req.StudentID, + Role: role, + } + + result, err := s.repo.Create(ctx, entry) + if err != nil { + return nil, fmt.Errorf("add student: %w", err) + } + + s.logger.Info("student added to roster", + zap.Int64("entry_id", result.ID), + zap.Int64("classroom_id", classroomID), + zap.String("student_id", req.StudentID), + ) + return result, nil +} + +// List returns a paginated list of roster entries. +func (s *RosterService) List(ctx context.Context, classroomID int64, req *model.RosterListRequest) (*model.RosterListResponse, error) { + filter := repository.RosterFilter{ + ClassroomID: classroomID, + LinkedOnly: req.LinkedOnly, + UnlinkedOnly: req.UnlinkedOnly, + } + pg := repository.Pagination{Page: req.Page, PerPage: req.PerPage} + + entries, total, err := s.repo.List(ctx, filter, pg) + if err != nil { + return nil, err + } + + return &model.RosterListResponse{ + Students: entries, + Total: total, + Page: pg.Page, + PerPage: pg.Limit(), + TotalPages: pg.TotalPages(total), + }, nil +} + +// GetByID returns a roster entry by its ID. +func (s *RosterService) GetByID(ctx context.Context, id int64) (*model.RosterEntry, error) { + e, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if e == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: roster entry %d not found", id) + } + return e, nil +} + +// Delete removes a student from the roster. +func (s *RosterService) Delete(ctx context.Context, id int64) error { + return s.repo.Delete(ctx, id) +} + +// LinkStudent links a roster entry to a platform user account by +// looking up the username on the configured git platform. +func (s *RosterService) LinkStudent(ctx context.Context, entryID int64, req *model.LinkStudentRequest) (*model.RosterEntry, error) { + v := util.NewValidator() + v.ValidateRequired("platform_username", req.PlatformUsername, "Platform username") + if v.HasErrors() { + return nil, v.Errors() + } + + // Verify entry exists + entry, err := s.repo.GetByID(ctx, entryID) + if err != nil { + return nil, err + } + if entry == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: roster entry %d not found", entryID) + } + + // Look up the user on the platform + user, err := s.provider.GetUser(ctx, req.PlatformUsername) + if err != nil { + return nil, fmt.Errorf("INTEGRATION_PLATFORM_API_ERROR: user %q not found: %w", req.PlatformUsername, err) + } + + result, err := s.repo.LinkStudent(ctx, entryID, user.Username, user.ID) + if err != nil { + return nil, fmt.Errorf("link student: %w", err) + } + + s.logger.Info("student linked", + zap.Int64("entry_id", entryID), + zap.String("platform_username", user.Username), + ) + return result, nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..6d5ccd8 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,38 @@ +// Package service implements the business-logic layer. Each service +// consumes repository interfaces and a platform.Provider to coordinate +// database operations with external git-platform calls. +package service + +import ( + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" +) + +// Services aggregates every domain service for easy dependency injection. +type Services struct { + Classroom *ClassroomService + Assignment *AssignmentService + Roster *RosterService + Team *TeamService + Submission *SubmissionService +} + +// NewServices creates all services from shared infrastructure. +func NewServices(db *database.DB, provider platform.Provider, logger *zap.Logger) *Services { + classroomRepo := repository.NewClassroomRepository(db.DB) + assignmentRepo := repository.NewAssignmentRepository(db.DB) + rosterRepo := repository.NewRosterRepository(db.DB) + teamRepo := repository.NewTeamRepository(db.DB) + submissionRepo := repository.NewSubmissionRepository(db.DB) + + return &Services{ + Classroom: NewClassroomService(classroomRepo, provider, db, logger), + Assignment: NewAssignmentService(assignmentRepo, classroomRepo, provider, db, logger), + Roster: NewRosterService(rosterRepo, classroomRepo, provider, logger), + Team: NewTeamService(teamRepo, assignmentRepo, rosterRepo, db, logger), + Submission: NewSubmissionService(submissionRepo, assignmentRepo, rosterRepo, teamRepo, provider, db, logger), + } +} diff --git a/internal/service/submission.go b/internal/service/submission.go new file mode 100644 index 0000000..400407a --- /dev/null +++ b/internal/service/submission.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/platform" + "code.forgejo.org/forgejo/classroom/internal/repository" +) + +// SubmissionService handles submission business logic. +type SubmissionService struct { + repo repository.SubmissionRepository + assignmentRepo repository.AssignmentRepository + rosterRepo repository.RosterRepository + teamRepo repository.TeamRepository + provider platform.Provider + db *database.DB + logger *zap.Logger +} + +// NewSubmissionService creates a new SubmissionService. +func NewSubmissionService( + repo repository.SubmissionRepository, + assignmentRepo repository.AssignmentRepository, + rosterRepo repository.RosterRepository, + teamRepo repository.TeamRepository, + provider platform.Provider, + db *database.DB, + logger *zap.Logger, +) *SubmissionService { + return &SubmissionService{ + repo: repo, assignmentRepo: assignmentRepo, + rosterRepo: rosterRepo, teamRepo: teamRepo, + provider: provider, db: db, logger: logger, + } +} + +// AcceptAssignment creates a submission by forking/generating a repository +// from the assignment template and granting the student access. +func (s *SubmissionService) AcceptAssignment(ctx context.Context, assignmentID, studentID int64) (*model.Submission, error) { + // 1. Verify assignment exists + assignment, err := s.assignmentRepo.GetByID(ctx, assignmentID) + if err != nil { + return nil, err + } + if assignment == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: assignment %d not found", assignmentID) + } + + // 2. Check deadline + if assignment.IsPast() { + return nil, fmt.Errorf("BUSINESS_DEADLINE_PASSED: deadline was %s", assignment.Deadline.Format(time.RFC3339)) + } + + // 3. Verify student is in roster + student, err := s.rosterRepo.GetByID(ctx, studentID) + if err != nil { + return nil, err + } + if student == nil { + return nil, fmt.Errorf("BUSINESS_ROSTER_NOT_FOUND: student %d not in roster", studentID) + } + + // 4. Check idempotency -- already accepted? + existing, err := s.repo.GetByAssignmentAndStudent(ctx, assignmentID, studentID) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil // Idempotent: return existing submission + } + + // 5. Create repository from template on the platform + repoName := fmt.Sprintf("%s-%s", assignment.Slug, student.StudentID) + ownerRepo := splitOwnerRepo(assignment.TemplateRepository) + classroom, err := s.assignmentRepo.GetByID(ctx, assignment.ClassroomID) + if err != nil { + return nil, err + } + // Use the classroom's organization as the repo owner + _ = classroom // organization_name would be used for the owner + + repo, err := s.provider.CreateRepositoryFromTemplate(ctx, assignment.TemplateRepositoryID, platform.CreateRepoOptions{ + Owner: ownerRepo[0], + Name: repoName, + Description: fmt.Sprintf("Submission for %s by %s", assignment.Name, student.StudentName), + Private: true, + }) + if err != nil { + return nil, fmt.Errorf("INTEGRATION_PLATFORM_API_ERROR: failed to create repo: %w", err) + } + + // 6. Grant student write access + if student.PlatformUserID != nil { + if collab, ok := s.provider.(platform.CollaboratorProvider); ok { + if err := collab.AddCollaborator(ctx, repo.ID, *student.PlatformUserID, platform.PermissionWrite); err != nil { + // Attempt cleanup + _ = s.provider.DeleteRepository(ctx, ownerRepo[0], repoName) + return nil, fmt.Errorf("INTEGRATION_PLATFORM_API_ERROR: failed to grant access: %w", err) + } + } + } + + // 7. Persist submission + now := time.Now().UTC() + status := "accepted" + if assignment.IsPast() { + status = "late" + } + + submission := &model.Submission{ + AssignmentID: assignmentID, + StudentID: &studentID, + RepositoryName: repo.FullName, + RepositoryID: repo.ID, + RepositoryURL: repo.CloneURL, + Status: status, + AcceptedAt: &now, + } + + result, err := s.repo.Create(ctx, submission) + if err != nil { + return nil, fmt.Errorf("create submission: %w", err) + } + + s.logger.Info("assignment accepted", + zap.Int64("submission_id", result.ID), + zap.Int64("assignment_id", assignmentID), + zap.Int64("student_id", studentID), + ) + return result, nil +} + +// GetByID returns a submission by its ID. +func (s *SubmissionService) GetByID(ctx context.Context, id int64) (*model.Submission, error) { + sub, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if sub == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: submission %d not found", id) + } + return sub, nil +} + +// List returns a paginated list of submissions with filters. +func (s *SubmissionService) List(ctx context.Context, filter repository.SubmissionFilter, pg repository.Pagination) ([]model.Submission, int, error) { + return s.repo.List(ctx, filter, pg) +} + +// ListByAssignment returns all submissions for a given assignment. +func (s *SubmissionService) ListByAssignment(ctx context.Context, assignmentID int64, pg repository.Pagination) ([]model.Submission, int, error) { + filter := repository.SubmissionFilter{AssignmentID: &assignmentID} + return s.repo.List(ctx, filter, pg) +} diff --git a/internal/service/team.go b/internal/service/team.go new file mode 100644 index 0000000..0b3928c --- /dev/null +++ b/internal/service/team.go @@ -0,0 +1,180 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + + "go.uber.org/zap" + + "code.forgejo.org/forgejo/classroom/internal/database" + "code.forgejo.org/forgejo/classroom/internal/model" + "code.forgejo.org/forgejo/classroom/internal/repository" + "code.forgejo.org/forgejo/classroom/internal/util" +) + +// TeamService handles team business logic. +type TeamService struct { + repo repository.TeamRepository + assignmentRepo repository.AssignmentRepository + rosterRepo repository.RosterRepository + db *database.DB + logger *zap.Logger +} + +// NewTeamService creates a new TeamService. +func NewTeamService( + repo repository.TeamRepository, + assignmentRepo repository.AssignmentRepository, + rosterRepo repository.RosterRepository, + db *database.DB, + logger *zap.Logger, +) *TeamService { + return &TeamService{ + repo: repo, assignmentRepo: assignmentRepo, + rosterRepo: rosterRepo, db: db, logger: logger, + } +} + +// Create validates and creates a new team for a team-based assignment. +func (s *TeamService) Create(ctx context.Context, req *model.CreateTeamRequest, leaderStudentID int64) (*model.Team, error) { + v := util.NewValidator() + v.ValidateRequired("name", req.Name, "Team name") + v.ValidateLength("name", req.Name, "Team name", 1, 255) + if v.HasErrors() { + return nil, v.Errors() + } + + // Verify assignment exists and is a team assignment + assignment, err := s.assignmentRepo.GetByID(ctx, req.AssignmentID) + if err != nil { + return nil, err + } + if assignment == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: assignment %d not found", req.AssignmentID) + } + if assignment.IsIndividualAssignment() { + return nil, fmt.Errorf("BUSINESS_OPERATION_NOT_ALLOWED: assignment %d is individual-only", req.AssignmentID) + } + + slug := util.GenerateSlug(req.Name) + + // Check slug uniqueness within assignment + existing, err := s.repo.GetByAssignmentAndSlug(ctx, req.AssignmentID, slug) + if err != nil { + return nil, err + } + if existing != nil { + return nil, fmt.Errorf("RESOURCE_ALREADY_EXISTS: team with slug %q already exists for this assignment", slug) + } + + // Verify leader is in the classroom roster + rosterEntry, err := s.rosterRepo.GetByID(ctx, leaderStudentID) + if err != nil { + return nil, err + } + if rosterEntry == nil { + return nil, fmt.Errorf("BUSINESS_ROSTER_NOT_FOUND: student %d not found in roster", leaderStudentID) + } + + team := &model.Team{ + AssignmentID: req.AssignmentID, + Name: req.Name, + Slug: slug, + Description: req.Description, + LeaderID: leaderStudentID, + MemberCount: 1, // Leader is the first member + } + + var result *model.Team + err = s.db.WithTransaction(ctx, func(tx *sql.Tx) error { + var createErr error + result, createErr = s.repo.Create(ctx, team) + if createErr != nil { + return createErr + } + // Add leader as the first member + return s.repo.AddMember(ctx, result.ID, leaderStudentID, "leader") + }) + if err != nil { + return nil, fmt.Errorf("create team: %w", err) + } + + s.logger.Info("team created", + zap.Int64("id", result.ID), + zap.String("slug", result.Slug), + zap.Int64("assignment_id", req.AssignmentID), + ) + return result, nil +} + +// GetByID returns a team with its members. +func (s *TeamService) GetByID(ctx context.Context, id int64) (*model.TeamWithMembers, error) { + tw, err := s.repo.GetWithMembers(ctx, id) + if err != nil { + return nil, err + } + if tw == nil { + return nil, fmt.Errorf("RESOURCE_NOT_FOUND: team %d not found", id) + } + return tw, nil +} + +// ListByAssignment returns teams for an assignment. +func (s *TeamService) ListByAssignment(ctx context.Context, assignmentID int64, pg repository.Pagination) ([]model.Team, int, error) { + return s.repo.ListByAssignment(ctx, assignmentID, pg) +} + +// JoinTeam adds a student to a team, enforcing size limits. +func (s *TeamService) JoinTeam(ctx context.Context, teamID, studentID int64) error { + team, err := s.repo.GetByID(ctx, teamID) + if err != nil { + return err + } + if team == nil { + return fmt.Errorf("RESOURCE_NOT_FOUND: team %d not found", teamID) + } + + // Check team size + assignment, err := s.assignmentRepo.GetByID(ctx, team.AssignmentID) + if err != nil { + return err + } + if team.IsFull(assignment.MaxTeamSize) { + return fmt.Errorf("BUSINESS_TEAM_SIZE_EXCEEDED: team is full (%d/%d)", team.MemberCount, assignment.MaxTeamSize) + } + + // Check not already a member + isMember, err := s.repo.IsMember(ctx, teamID, studentID) + if err != nil { + return err + } + if isMember { + return fmt.Errorf("RESOURCE_ALREADY_EXISTS: student %d is already a member of team %d", studentID, teamID) + } + + return s.repo.AddMember(ctx, teamID, studentID, "member") +} + +// LeaveTeam removes a student from a team. +func (s *TeamService) LeaveTeam(ctx context.Context, teamID, studentID int64) error { + team, err := s.repo.GetByID(ctx, teamID) + if err != nil { + return err + } + if team == nil { + return fmt.Errorf("RESOURCE_NOT_FOUND: team %d not found", teamID) + } + + // Leaders cannot leave; they must delete the team + if team.LeaderID == studentID { + return fmt.Errorf("BUSINESS_OPERATION_NOT_ALLOWED: team leader cannot leave; delete the team instead") + } + + return s.repo.RemoveMember(ctx, teamID, studentID) +} + +// Delete removes a team. +func (s *TeamService) Delete(ctx context.Context, id int64) error { + return s.repo.Delete(ctx, id) +} diff --git a/migrations/000002_create_assignments.down.sql b/migrations/000002_create_assignments.down.sql new file mode 100644 index 0000000..ad65371 --- /dev/null +++ b/migrations/000002_create_assignments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS assignments; diff --git a/migrations/000002_create_assignments.up.sql b/migrations/000002_create_assignments.up.sql new file mode 100644 index 0000000..2421154 --- /dev/null +++ b/migrations/000002_create_assignments.up.sql @@ -0,0 +1,33 @@ +-- Create assignments table +CREATE TABLE assignments ( + id BIGSERIAL PRIMARY KEY, + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + template_repository VARCHAR(255) NOT NULL, + template_repository_id BIGINT NOT NULL DEFAULT 0, + deadline TIMESTAMP WITH TIME ZONE, + max_team_size INT NOT NULL DEFAULT 1, + auto_accept BOOLEAN NOT NULL DEFAULT true, + public BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Unique slug per classroom +ALTER TABLE assignments ADD CONSTRAINT uk_assignments_classroom_slug + UNIQUE (classroom_id, slug); + +-- Constraints +ALTER TABLE assignments ADD CONSTRAINT chk_assignments_slug_format + CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'); +ALTER TABLE assignments ADD CONSTRAINT chk_assignments_name_length + CHECK (char_length(name) >= 1 AND char_length(name) <= 255); +ALTER TABLE assignments ADD CONSTRAINT chk_assignments_max_team_size + CHECK (max_team_size >= 1 AND max_team_size <= 100); + +-- Indexes +CREATE INDEX idx_assignments_classroom_id ON assignments(classroom_id); +CREATE INDEX idx_assignments_deadline ON assignments(deadline) WHERE deadline IS NOT NULL; +CREATE INDEX idx_assignments_created_at ON assignments(created_at); diff --git a/migrations/000003_create_roster_entries.down.sql b/migrations/000003_create_roster_entries.down.sql new file mode 100644 index 0000000..be8c1a5 --- /dev/null +++ b/migrations/000003_create_roster_entries.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS roster_entries; diff --git a/migrations/000003_create_roster_entries.up.sql b/migrations/000003_create_roster_entries.up.sql new file mode 100644 index 0000000..5ffc273 --- /dev/null +++ b/migrations/000003_create_roster_entries.up.sql @@ -0,0 +1,32 @@ +-- Create roster_entries table +CREATE TABLE roster_entries ( + id BIGSERIAL PRIMARY KEY, + classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE, + student_name VARCHAR(255) NOT NULL, + student_email VARCHAR(255) NOT NULL, + student_id VARCHAR(100) NOT NULL, + platform_username VARCHAR(255), + platform_user_id BIGINT, + role VARCHAR(50) NOT NULL DEFAULT 'student', + linked_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Each student_id is unique within a classroom +ALTER TABLE roster_entries ADD CONSTRAINT uk_roster_classroom_student + UNIQUE (classroom_id, student_id); + +-- Constraints +ALTER TABLE roster_entries ADD CONSTRAINT chk_roster_role + CHECK (role IN ('student', 'assistant', 'instructor')); +ALTER TABLE roster_entries ADD CONSTRAINT chk_roster_email_format + CHECK (student_email ~ '^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$'); + +-- Indexes +CREATE INDEX idx_roster_classroom_id ON roster_entries(classroom_id); +CREATE INDEX idx_roster_student_email ON roster_entries(student_email); +CREATE INDEX idx_roster_platform_user_id ON roster_entries(platform_user_id) + WHERE platform_user_id IS NOT NULL; +CREATE INDEX idx_roster_linked ON roster_entries(linked_at) + WHERE linked_at IS NOT NULL; diff --git a/migrations/000004_create_teams.down.sql b/migrations/000004_create_teams.down.sql new file mode 100644 index 0000000..63f36c7 --- /dev/null +++ b/migrations/000004_create_teams.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS teams; diff --git a/migrations/000004_create_teams.up.sql b/migrations/000004_create_teams.up.sql new file mode 100644 index 0000000..99ca9c3 --- /dev/null +++ b/migrations/000004_create_teams.up.sql @@ -0,0 +1,26 @@ +-- Create teams table +CREATE TABLE teams ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + leader_id BIGINT NOT NULL REFERENCES roster_entries(id) ON DELETE RESTRICT, + member_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Unique slug per assignment +ALTER TABLE teams ADD CONSTRAINT uk_teams_assignment_slug + UNIQUE (assignment_id, slug); + +-- Constraints +ALTER TABLE teams ADD CONSTRAINT chk_teams_slug_format + CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'); +ALTER TABLE teams ADD CONSTRAINT chk_teams_name_length + CHECK (char_length(name) >= 1 AND char_length(name) <= 255); + +-- Indexes +CREATE INDEX idx_teams_assignment_id ON teams(assignment_id); +CREATE INDEX idx_teams_leader_id ON teams(leader_id); diff --git a/migrations/000005_create_team_members.down.sql b/migrations/000005_create_team_members.down.sql new file mode 100644 index 0000000..52c5182 --- /dev/null +++ b/migrations/000005_create_team_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS team_members; diff --git a/migrations/000005_create_team_members.up.sql b/migrations/000005_create_team_members.up.sql new file mode 100644 index 0000000..b728c13 --- /dev/null +++ b/migrations/000005_create_team_members.up.sql @@ -0,0 +1,20 @@ +-- Create team_members join table +CREATE TABLE team_members ( + id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + student_id BIGINT NOT NULL REFERENCES roster_entries(id) ON DELETE CASCADE, + role VARCHAR(50) NOT NULL DEFAULT 'member', + joined_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Each student can only be in a team once +ALTER TABLE team_members ADD CONSTRAINT uk_team_members + UNIQUE (team_id, student_id); + +-- Constraints +ALTER TABLE team_members ADD CONSTRAINT chk_team_member_role + CHECK (role IN ('leader', 'member')); + +-- Indexes +CREATE INDEX idx_team_members_team_id ON team_members(team_id); +CREATE INDEX idx_team_members_student_id ON team_members(student_id); diff --git a/migrations/000006_create_submissions.down.sql b/migrations/000006_create_submissions.down.sql new file mode 100644 index 0000000..15d6ae5 --- /dev/null +++ b/migrations/000006_create_submissions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS submissions; diff --git a/migrations/000006_create_submissions.up.sql b/migrations/000006_create_submissions.up.sql new file mode 100644 index 0000000..cdfa4da --- /dev/null +++ b/migrations/000006_create_submissions.up.sql @@ -0,0 +1,41 @@ +-- Create submissions table +CREATE TABLE submissions ( + id BIGSERIAL PRIMARY KEY, + assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + student_id BIGINT REFERENCES roster_entries(id) ON DELETE SET NULL, + team_id BIGINT REFERENCES teams(id) ON DELETE SET NULL, + repository_name VARCHAR(255) NOT NULL, + repository_id BIGINT NOT NULL, + repository_url TEXT NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + accepted_at TIMESTAMP WITH TIME ZONE, + last_commit_sha VARCHAR(40), + last_commit_message TEXT, + commit_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Must have either a student or a team owner +ALTER TABLE submissions ADD CONSTRAINT chk_submission_has_owner + CHECK (student_id IS NOT NULL OR team_id IS NOT NULL); + +-- Status must be a known value +ALTER TABLE submissions ADD CONSTRAINT chk_submission_status + CHECK (status IN ('pending', 'accepted', 'late')); + +-- One submission per student per assignment +CREATE UNIQUE INDEX uk_submission_student + ON submissions(assignment_id, student_id) WHERE student_id IS NOT NULL; + +-- One submission per team per assignment +CREATE UNIQUE INDEX uk_submission_team + ON submissions(assignment_id, team_id) WHERE team_id IS NOT NULL; + +-- Indexes +CREATE INDEX idx_submissions_assignment_id ON submissions(assignment_id); +CREATE INDEX idx_submissions_student_id ON submissions(student_id) + WHERE student_id IS NOT NULL; +CREATE INDEX idx_submissions_team_id ON submissions(team_id) + WHERE team_id IS NOT NULL; +CREATE INDEX idx_submissions_status ON submissions(status);