Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,19 @@ jobs:
run: chmod +x ./gradlew

- name: Build and test
run: ./gradlew clean build
run: ./gradlew clean build --info --console=plain

- name: Generate license report
run: ./gradlew generateLicenseReport

- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/test-results/test
path: build/test-results/test

- name: Upload license report
uses: actions/upload-artifact@v4
with:
name: license-report
path: build/reports/dependency-license
674 changes: 674 additions & 0 deletions LICENSE.txt

Large diffs are not rendered by default.

155 changes: 154 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,154 @@
# server
# "TimeTamer" SmartCalendar Server

Spring Boot backend for the "TimeTamer" SmartCalendar application. Provides REST API for task/event management, user statistics, and OpenAI integration.

---

## Key Features
- **User Management**: Registration, authentication, and profile updates
- **Task & Event Operations**: Full CRUD functionality with status tracking
- **JWT Authentication**: Secure token-based access control
- **OpenAI Integration**:
- ChatGPT for natural language processing
- Whisper for speech-to-text
- **Automated Documentation**: Swagger UI for interactive API exploration
- **CI/CD Pipeline**: GitHub Actions for automated testing and deployment

---

## Tech Stack
- **Language**: Java 21
- **Framework**: Spring Boot 3.1+
- **Database**:
- PostgreSQL (Production)
- H2 (Development/Testing)
- **Build Tool**: Gradle 8+

---

## Prerequisites
- Java 21 JDK
- Gradle 8+
- PostgreSQL 15+ (for production)
- OpenAI API key

---

## Quick Start (Development)
1. Clone repository:
```bash
git clone https://github.com/hse-project-Java-2025/server.git
cd smartcalendar-server
```
2. Set environment variables (create `.env` file):
```ini
JWT_SECRET=your_strong_secret_here
CHATGPT_API_KEY=your_openai_api_key
```
3. Build and run:
```bash
./gradlew bootRun
```
4. Access resources:
- **Swagger UI**: `http://localhost:8080/swagger-ui.html` (complete API documentation)
- H2 Console: `http://localhost:8080/h2-console` (JDBC URL: `jdbc:h2:mem:testdb`)

---

## Configuration
### Essential Environment Variables
| Variable | Description | Example |
|-------------------|-------------------------------------|-----------------------------|
| `JWT_SECRET` | Secret for JWT token signing | `A$ecretKey!123` |
| `CHATGPT_API_KEY` | OpenAI API key | `sk-...` |
| `DB_URL` | Production DB URL (optional) | `jdbc:postgresql://db:5432` |

### Production Setup
1. Create `application-prod.properties`:
```properties
spring.datasource.url=jdbc:postgresql://your-db-host:5432/smartcalendar
spring.datasource.username=dbuser
spring.datasource.password=dbpassword
spring.jpa.hibernate.ddl-auto=update
```
2. Build executable JAR:
```bash
./gradlew clean build
```
3. Run with production profile:
```bash
java -Dspring.profiles.active=prod -jar build/libs/smartcalendar-*.jar
```

---

## API Reference
### Core Endpoints Overview
Below are representative examples of available API endpoints. Complete and always up-to-date definitive API reference is automatically generated at runtime:
```http
http://localhost:8080/swagger-ui.html
```


### User Management
| Endpoint | Method | Description |
|-----------------------------------|--------|------------------------------|
| `/api/users` | GET | List all users |
| `/api/users` | POST | Register new user |
| `/api/users/{id}` | GET | Get user details |
| `/api/users/{id}/email` | PUT | Update user email |
| `/api/users/{userId}/statistics` | GET | Get user statistics |

### Task Management
| Endpoint | Method | Description |
|---------------------------------------|--------|------------------------------|
| `/api/users/{userId}/tasks` | GET | Get user's tasks |
| `/api/users/{userId}/tasks` | POST | Create new task |
| `/api/users/tasks/{taskId}/status` | PATCH | Update task status |
| `/api/users/tasks/{taskId}` | DELETE | Delete task |

### Event Management
| Endpoint | Method | Description |
|---------------------------------------|--------|------------------------------|
| `/api/users/{userId}/events` | GET | Get user's events |
| `/api/users/{userId}/events` | POST | Create new event |
| `/api/users/events/{eventId}` | PATCH | Update event |
| `/api/users/events/{eventId}` | DELETE | Delete event |

### OpenAI Integration
| Endpoint | Method | Description |
|-------------------------------|--------|--------------------------------------|
| `/api/chatgpt/ask` | POST | Get ChatGPT response |
| `/api/chatgpt/generate` | POST | Generate calendar events/tasks |
| `/api/chatgpt/generate/entities` | POST | Generate entities from natural language |

---

## Testing
Run tests with:
```bash
./gradlew test
```
- Uses separate in-memory H2 database
- External services (OpenAI) are mocked
- Test coverage reports: `build/reports/tests`

---

## CI/CD Pipeline
GitHub Actions workflow (`.github/workflows/ci.yml`):
1. Build with JDK 21
2. Run all tests
3. Generate dependency license report
4. Upload test reports as artifacts

---

## License
MIT License - see [LICENSE](LICENSE.txt) file

---

## Contributors
- [Dmitry Rusanov](https://github.com/DimaRus05)
- [Mikhail Minaev](https://github.com/minmise)
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ plugins {
id 'java'
id 'org.springframework.boot' version '3.4.3'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.github.jk1.dependency-license-report' version '2.8'
id 'com.adarshr.test-logger' version '4.0.0'
}

group = 'com.smartcalendar'
version = '0.0.1-SNAPSHOT'
version = '0.0.3-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

testlogger {
theme 'mocha'
}

repositories {
mavenCentral()
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/smartcalendar/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
Expand All @@ -25,6 +26,7 @@
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Profile("!test")
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserService userService;
Expand Down
58 changes: 38 additions & 20 deletions src/main/java/com/smartcalendar/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package com.smartcalendar.controller;

import com.smartcalendar.dto.RegistrationRequest;
import com.smartcalendar.model.User;
import com.smartcalendar.service.JwtService;
import com.smartcalendar.service.StatisticsService;
import com.smartcalendar.service.UserService;
import com.smartcalendar.dto.ChangeCredentialsRequest;
import com.smartcalendar.dto.StatisticsData;
import com.smartcalendar.dto.AverageDayTimeDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
Expand All @@ -21,6 +25,8 @@
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
Expand All @@ -30,6 +36,7 @@ public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final UserService userService;
private final StatisticsService statisticsService;

@Operation(
summary = "User authentication",
Expand All @@ -44,16 +51,16 @@ public class AuthController {
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@RequestBody User user) {
logger.info("Attempting to authenticate user: {}", user.getUsername());

if ((user.getUsername() == null && user.getEmail() == null) || user.getPassword() == null) {
logger.warn("Username/email or password is null. Username: {}, Email: {}", user.getUsername(), user.getEmail());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Username/email and password are required");
}

try {
logger.debug("Determining if login is by username or email: {}", user.getUsername() != null ? user.getUsername() : user.getEmail());
UserDetails userDetails;

try {
if (user.getUsername() != null) {
userDetails = userService.loadUserByUsername(user.getUsername());
Expand All @@ -64,17 +71,17 @@ public ResponseEntity<?> authenticateUser(@RequestBody User user) {
logger.error("User not found: {}", user.getUsername() != null ? user.getUsername() : user.getEmail());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid username or email");
}

Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword())
new UsernamePasswordAuthenticationToken(userDetails.getUsername(), user.getPassword())
);

logger.debug("Authentication successful for user: {}", userDetails.getUsername());
SecurityContextHolder.getContext().setAuthentication(authentication);

String jwt = jwtService.generateToken(userDetails.getUsername());
logger.info("JWT token generated for user: {}", userDetails.getUsername());

return ResponseEntity.ok(jwt);
} catch (BadCredentialsException e) {
logger.error("Invalid credentials for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail());
Expand All @@ -83,7 +90,7 @@ public ResponseEntity<?> authenticateUser(@RequestBody User user) {
logger.error("Unexpected error during authentication for user: {}", user.getUsername() != null ? user.getUsername() : user.getEmail(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An unexpected error occurred");
}
}
}

@Operation(
summary = "New user signup",
Expand All @@ -95,29 +102,40 @@ public ResponseEntity<?> authenticateUser(@RequestBody User user) {
@ApiResponse(responseCode = "500", description = "Internal server error")
})
@PostMapping("/signup")
public ResponseEntity<User> registerUser(@RequestBody User user) {
logger.info("Attempting to register user: {}", user.getUsername());
public ResponseEntity<User> registerUser(@RequestBody RegistrationRequest request) {
logger.info("Attempting to register user: {}", request.getUsername());

if (user.getUsername() == null || user.getPassword() == null || user.getEmail() == null) {
logger.warn("Missing required fields for registration. Username: {}, Email: {}", user.getUsername(), user.getEmail());
if (request.getUsername() == null || request.getPassword() == null || request.getEmail() == null || request.getFirstDay() == null) {
logger.warn("Missing required fields for registration. Username: {}, Email: {}, FirstDay: {}", request.getUsername(), request.getEmail(), request.getFirstDay());
return ResponseEntity.badRequest().body(null);
}

if (userService.existsByUsername(user.getUsername())) {
logger.warn("Username already exists: {}", user.getUsername());
if (userService.existsByUsername(request.getUsername())) {
logger.warn("Username already exists: {}", request.getUsername());
return ResponseEntity.badRequest().body(null);
}
if (userService.existsByEmail(user.getEmail())) {
logger.warn("Email already exists: {}", user.getEmail());
if (userService.existsByEmail(request.getEmail())) {
logger.warn("Email already exists: {}", request.getEmail());
return ResponseEntity.badRequest().body(null);
}

try {
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPassword(request.getPassword());
User createdUser = userService.createUser(user);
logger.info("User registered successfully: {}", user.getUsername());

StatisticsData statisticsData = new StatisticsData();
statisticsData.setAverageDayTime(
new AverageDayTimeDto(0, LocalDate.parse(request.getFirstDay()))
);
statisticsService.updateStatistics(createdUser.getId(), statisticsData);

logger.info("User registered successfully: {}", request.getUsername());
return ResponseEntity.ok(createdUser);
} catch (Exception e) {
logger.error("Error during user registration: {}", user.getUsername(), e);
logger.error("Error during user registration: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
}
}
Expand All @@ -130,7 +148,7 @@ public ResponseEntity<?> changeCredentials(@RequestBody ChangeCredentialsRequest
request.getNewUsername(),
request.getNewPassword()
);

if (success) {
return ResponseEntity.ok("Credentials updated successfully");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,12 @@ public ResponseEntity<?> generateEntities(@RequestBody Map<String, String> reque
return ResponseEntity.badRequest().body(response);
}

if (!(response.get("events") instanceof List) || !(response.get("tasks") instanceof List)) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid response format from ChatGPT"));
}
List<?> events = response.get("events") instanceof List ? (List<?>) response.get("events") : List.of();
List<?> tasks = response.get("tasks") instanceof List ? (List<?>) response.get("tasks") : List.of();

Map<String, List<?>> validResponse = Map.of(
"events", (List<?>) response.get("events"),
"tasks", (List<?>) response.get("tasks")
"events", events,
"tasks", tasks
);

List<Object> entities = chatGPTService.convertToEntities(validResponse);
Expand Down
Loading