Compare commits
14 Commits
d6657d83dd
...
09b6193077
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b6193077 | |||
| 61f9404a8a | |||
| f53422fa8c | |||
| 86664921ae | |||
| 04f7375651 | |||
| 629ae4c754 | |||
| 9bb40249ea | |||
| 4e5dee2e45 | |||
| 6559a9cd4b | |||
| a9278b8269 | |||
| 4db77055ed | |||
| 73c9bc1f14 | |||
| 7d4ffee203 | |||
| 6e8cb8d560 |
@@ -0,0 +1,30 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Make Gradle wrapper executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Run tests
|
||||
run: ./gradlew test --no-daemon
|
||||
|
||||
# TODO: CD logic — deploy after successful tests on merge to main
|
||||
+62
-61
@@ -1,18 +1,8 @@
|
||||
backend/.db/
|
||||
backend/.env
|
||||
backend/todo
|
||||
backend/.creds
|
||||
backend/init/*
|
||||
backend/*temp.java
|
||||
backend/src/main/java/dev/ksan/etfoglasiserver/temp.java
|
||||
########################
|
||||
# GLOBAL (all projects)
|
||||
########################
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Java / Gradle
|
||||
backend/.gradle/
|
||||
backend/build/
|
||||
todo
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
@@ -21,7 +11,32 @@ backend/build/
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Eclipse / STS
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
########################
|
||||
# BACKEND (Spring Boot)
|
||||
########################
|
||||
|
||||
backend/.gradle/
|
||||
backend/build/
|
||||
backend/bin/
|
||||
backend/src/main/resources/application.properties
|
||||
|
||||
backend/.env
|
||||
backend/.creds
|
||||
backend/.db/
|
||||
|
||||
backend/init/*
|
||||
backend/**/*temp.java
|
||||
|
||||
backend/src/main/resources/google-services.json
|
||||
backend/src/main/resources/etf-oglasi-firebase.json
|
||||
|
||||
# Java / Eclipse / STS
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
@@ -34,54 +49,40 @@ nbbuild/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
# Environment & secrets
|
||||
backend/.env
|
||||
backend/.creds
|
||||
########################
|
||||
# FRONTEND (React Native / Expo)
|
||||
########################
|
||||
|
||||
# Local database
|
||||
backend/.db/
|
||||
frontend/node_modules/
|
||||
|
||||
## frontend
|
||||
frontend/google-services.json
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
frontend/.expo/
|
||||
frontend/dist/
|
||||
frontend/web-build/
|
||||
|
||||
frontend/.kotlin/
|
||||
frontend/*.orig.*
|
||||
frontend/*.jks
|
||||
frontend/*.p8
|
||||
frontend/*.p12
|
||||
frontend/*.key
|
||||
frontend/*.mobileprovision
|
||||
|
||||
frontend/.metro-health-check*
|
||||
|
||||
frontend/npm-debug.*
|
||||
frontend/yarn-debug.*
|
||||
frontend/yarn-error.*
|
||||
|
||||
frontend/.env
|
||||
frontend/.env*.local
|
||||
|
||||
frontend/*.tsbuildinfo
|
||||
|
||||
# Native builds
|
||||
frontend/ios/
|
||||
frontend/android/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
frontend/.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
frontend/expo-env.d.ts
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Full-Stack Learning Project
|
||||
|
||||
This repository is a full-stack learning project focused on building and understanding modern application development practices end-to-end.
|
||||
|
||||
It demonstrates how to design, build, and deploy a real-world application using a backend API, mobile frontend, database integration, and CI/CD workflows.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Spring Boot
|
||||
- RESTful API architecture
|
||||
- PostgreSQL database
|
||||
|
||||
### Frontend
|
||||
- React Native (Expo)
|
||||
|
||||
### DevOps / Tooling
|
||||
- Docker
|
||||
- CI/CD pipelines (Gitea Actions or similar)
|
||||
- Git version control
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The goal of this project is to gain hands-on experience with:
|
||||
|
||||
- Full-stack application architecture
|
||||
- Backend API development with Spring Boot
|
||||
- Mobile development with React Native
|
||||
- Database design and integration
|
||||
- Authentication and security concepts
|
||||
- Containerization with Docker
|
||||
- Continuous Integration / Continuous Deployment (CI/CD)
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
The application is deployed on a self-hosted home server for development and testing purposes.
|
||||
|
||||
---
|
||||
|
||||
## How to Run (Backend)
|
||||
|
||||
To be added.
|
||||
|
||||
Planned steps:
|
||||
- Install Java and Gradle
|
||||
- Configure PostgreSQL database
|
||||
- Set environment variables
|
||||
- Run Spring Boot application
|
||||
- Expose API on localhost or network
|
||||
- I usually run eveything with traefik but i dont know should i include that here???
|
||||
|
||||
---
|
||||
|
||||
## How to Run (Frontend)
|
||||
|
||||
To be added...
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CI/CD
|
||||
|
||||
To be added.
|
||||
|
||||
Planned steps:
|
||||
- Configure Gitea Actions / CI pipeline
|
||||
- Build backend and frontend automatically
|
||||
- Deploy to home server
|
||||
- Automate testing and builds
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
This project is currently under semi-active development as a learning and experimentation environment.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
This project is just me messing around with stuff while making an app i will use and perhaps others will find it useful
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
spring.application.name=etfoglasi-server
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
|
||||
spring.datasource.username=test
|
||||
spring.datasource.password=test
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,6 +39,19 @@ dependencies {
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
|
||||
implementation("org.projectlombok:lombok:1.18.42")
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||
implementation 'com.google.firebase:firebase-admin:9.9.0'
|
||||
|
||||
|
||||
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testRuntimeOnly 'com.h2database:h2'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation("org.testcontainers:postgresql:1.21.3")
|
||||
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
|
||||
testImplementation("org.mockito:mockito-inline:5.2.0")
|
||||
testImplementation 'org.mockito:mockito-core'
|
||||
}
|
||||
|
||||
generateJava {
|
||||
@@ -49,4 +62,8 @@ generateJava {
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
|
||||
jvmArgs += [
|
||||
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('byte-buddy-agent') }}"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ services:
|
||||
image: 'postgres:16'
|
||||
container_name: etfo-db
|
||||
environment:
|
||||
- 'POSTGRES_DB=etfo-db'
|
||||
- 'POSTGRES_PASSWORD=test'
|
||||
- 'POSTGRES_USER=test'
|
||||
- POSTGRES_DB=etfo-db
|
||||
- POSTGRES_PASSWORD=test
|
||||
- POSTGRES_USER=test
|
||||
volumes:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- ./.db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
- 5432:5432
|
||||
|
||||
+27
-1
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
|
||||
CONSTRAINT subjects_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public."user-subject"
|
||||
CREATE TABLE IF NOT EXISTS public."user_subject"
|
||||
(
|
||||
user_id uuid NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
@@ -55,6 +55,32 @@ CREATE TABLE IF NOT EXISTS public.users
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.device_tokens
|
||||
(
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL,
|
||||
fcm_token text NOT NULL,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT device_tokens_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT fk_device_tokens_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES public.users(id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT uq_device_tokens_fcm_token
|
||||
UNIQUE (fcm_token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_tokens_user_id
|
||||
ON public.device_tokens(user_id);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ALTER TABLE IF EXISTS public.alt_subject
|
||||
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
|
||||
REFERENCES public.subjects (id) MATCH SIMPLE
|
||||
|
||||
@@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class EtfoglasiServerApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package dev.ksan.etfoglasiserver.config;
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.FirebaseOptions;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class FirebaseConfig {
|
||||
|
||||
@Bean
|
||||
public FirebaseApp firebaseApp() throws IOException {
|
||||
GoogleCredentials credentials = GoogleCredentials
|
||||
.fromStream(new ClassPathResource("etf-oglasi-firebase.json").getInputStream())
|
||||
.createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging"));
|
||||
|
||||
FirebaseOptions options = FirebaseOptions.builder()
|
||||
.setCredentials(credentials)
|
||||
.build();
|
||||
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
return FirebaseApp.initializeApp(options);
|
||||
}
|
||||
return FirebaseApp.getInstance();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) {
|
||||
return FirebaseMessaging.getInstance(firebaseApp);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
String token = null;
|
||||
String username = null;
|
||||
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
username = jwtService.extractEmail(token);
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@@ -61,4 +62,10 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.ksan.etfoglasiserver.controller;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
|
||||
import dev.ksan.etfoglasiserver.service.DeviceTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/device-tokens")
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceTokenController {
|
||||
|
||||
private final DeviceTokenService deviceTokenService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<Void> register(
|
||||
@RequestBody RegisterTokenRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
deviceTokenService.registerToken(
|
||||
principal.getUsername(),
|
||||
request.getToken());
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -36,16 +38,30 @@ public class EntryController {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@GetMapping("/entries")
|
||||
public Page<Entry> getEntries(
|
||||
@RequestParam(required = false) String subjectId,
|
||||
@RequestParam(required = false) String groupName,
|
||||
@RequestParam(defaultValue = "0") int page
|
||||
) {
|
||||
UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
||||
// UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
||||
|
||||
UUID parsedSubjectId = null;
|
||||
if (subjectId != null) {
|
||||
try{
|
||||
parsedSubjectId = UUID.fromString(subjectId);
|
||||
}catch (IllegalArgumentException e){
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid subject ID");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return service.getEntries(parsedSubjectId, groupName, page);
|
||||
}
|
||||
|
||||
|
||||
@PutMapping("/entries")
|
||||
public void updateEntry(@RequestBody EntryDTO entry) {
|
||||
service.updateEntry(entry);
|
||||
|
||||
@@ -23,13 +23,13 @@ public class SubjectAltController {
|
||||
return service.getSubjectAlt(subjectId);
|
||||
}
|
||||
|
||||
@PostMapping("/subjectalt")
|
||||
public void addSubject(@RequestBody SubjectAlt subject) {
|
||||
service.addSubjectAlt(subject);
|
||||
}
|
||||
|
||||
@DeleteMapping("/subjectalt/{subjectId}")
|
||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
service.deleteSubjectAlt(subjectId);
|
||||
}
|
||||
// @PostMapping("/subjectalt")
|
||||
// public void addSubject(@RequestBody SubjectAlt subject) {
|
||||
// service.addSubjectAlt(subject);
|
||||
// }
|
||||
//
|
||||
// @DeleteMapping("/subjectalt/{subjectId}")
|
||||
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
// service.deleteSubjectAlt(subjectId);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -22,18 +22,21 @@ public class SubjectController {
|
||||
return service.getSubject(subjectId);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/subjects")
|
||||
public void addSubject(@RequestBody Subject subject) {
|
||||
service.addSubject(subject);
|
||||
}
|
||||
|
||||
@PutMapping("/subjects")
|
||||
public void updateSubject(@RequestBody Subject subject) {
|
||||
service.updateSubject(subject);
|
||||
}
|
||||
//TODO uncomment this if ever roles are added so only admin should modify this (didnt really feel like doing it rn)
|
||||
//
|
||||
// @PutMapping("/subjects")
|
||||
// public void updateSubject(@RequestBody Subject subject) {
|
||||
// service.updateSubject(subject);
|
||||
// }
|
||||
|
||||
@DeleteMapping("/subjects/{subjectId}")
|
||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
service.deleteSubject(subjectId);
|
||||
}
|
||||
// @DeleteMapping("/subjects/{subjectId}")
|
||||
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
// service.deleteSubject(subjectId);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.service.UserService;
|
||||
import java.util.List;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -20,76 +24,106 @@ public class UserController {
|
||||
@Autowired UserService service;
|
||||
|
||||
|
||||
@GetMapping("users/{userId}")
|
||||
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID userId) {
|
||||
@GetMapping("/users/me")
|
||||
public ResponseEntity<UserDTO> getUserById(Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO user = service.getUserById(userId);
|
||||
User user = service.getAccountByEmail(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "User not found"
|
||||
));
|
||||
|
||||
if (user != null) {
|
||||
return new ResponseEntity<>(user, HttpStatus.OK);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
return ResponseEntity.ok(new UserDTO(user));
|
||||
}
|
||||
|
||||
@PutMapping("/users")
|
||||
public ResponseEntity<UserDTO> updateUser(@RequestBody UserCreationDTO user) {
|
||||
@PutMapping("/users/me")
|
||||
public ResponseEntity<UserDTO> updateUser(
|
||||
@RequestBody UserCreationDTO user,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO userUpdated = service.updateUser(user);
|
||||
if (userUpdated != null) {
|
||||
return new ResponseEntity<>(userUpdated, HttpStatus.OK);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO userUpdated = service.updateUser(email, user);
|
||||
|
||||
return ResponseEntity.ok(userUpdated);
|
||||
}
|
||||
|
||||
@PutMapping("/users/{userId}/subjects/{subjectId}")
|
||||
|
||||
|
||||
@PostMapping("/users/me/notification-type")
|
||||
public ResponseEntity<?> updateNotificationType(
|
||||
@RequestBody Map<String, String> request,
|
||||
Authentication authentication) {
|
||||
|
||||
String email = authentication.getName();
|
||||
NotificationMethod type = NotificationMethod.valueOf(request.get("notificationType"));
|
||||
service.updateNotificationType(email, type);
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "updated"));
|
||||
}
|
||||
|
||||
@PutMapping("/users/me/subjects/{subjectId}")
|
||||
public ResponseEntity<UserDTO> addSubject(
|
||||
@PathVariable UUID userId,
|
||||
@PathVariable UUID subjectId
|
||||
) {
|
||||
@PathVariable UUID subjectId,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO updatedUser = service.addSubject(userId, subjectId);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO updatedUser = service.addSubject(email, subjectId);
|
||||
|
||||
return ResponseEntity.ok(updatedUser);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{userId}/subjects/{subjectId}")
|
||||
@DeleteMapping("/users/me/subjects/{subjectId}")
|
||||
public ResponseEntity<UserDTO> removeSubject(
|
||||
@PathVariable UUID userId,
|
||||
@PathVariable UUID subjectId
|
||||
) {
|
||||
@PathVariable UUID subjectId,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO updatedUser = service.removeSubject(userId, subjectId);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO updatedUser = service.removeSubject(email, subjectId);
|
||||
|
||||
return ResponseEntity.ok(updatedUser);
|
||||
}
|
||||
|
||||
// not the best, should fix this later
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
|
||||
try {
|
||||
JwtResponseDTO userVer = service.verify(user);
|
||||
|
||||
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
|
||||
}catch (Exception e) {
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// not the best, should fix this later too
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
|
||||
|
||||
try {
|
||||
UserDTO newUser = service.register(user);
|
||||
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED);
|
||||
}catch (Exception e) {
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
//
|
||||
// @DeleteMapping("/users/{userId}")
|
||||
// public void deleteUser(@PathVariable UUID userId) {
|
||||
// service.deleteUser(userId);
|
||||
// }
|
||||
|
||||
/*
|
||||
@PostMapping("/users")
|
||||
public void addUser(@RequestBody UserCreationDTO user) {
|
||||
NotificationMethod method = NotificationMethod.valueOf(user.getNotification());
|
||||
user.setNotificationMethod(method);
|
||||
service.addUser(user);
|
||||
|
||||
|
||||
@DeleteMapping("/users/me")
|
||||
public ResponseEntity<Void> deleteUser(Authentication authentication) {
|
||||
|
||||
String email = authentication.getName();
|
||||
|
||||
service.deleteUser(email);
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ public class UserCreationDTO {
|
||||
private Set<Subject> subjectSet;
|
||||
private String notification;
|
||||
|
||||
public UserCreationDTO(String mail, String password) {
|
||||
this.email = mail;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,26 @@ package dev.ksan.etfoglasiserver.dto;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class UserDTO {
|
||||
@Setter
|
||||
@Getter
|
||||
private UUID id;
|
||||
@Setter
|
||||
@Getter
|
||||
private String email;
|
||||
@Setter
|
||||
@Getter
|
||||
private Set<Subject> subjectSet;
|
||||
@Getter
|
||||
@Setter
|
||||
private NotificationMethod notificationType;
|
||||
|
||||
public UserDTO() {}
|
||||
|
||||
@@ -17,31 +30,17 @@ public class UserDTO {
|
||||
this.id = id;
|
||||
this.email = email;
|
||||
this.subjectSet = subjectSet;
|
||||
this.notificationType = notificationMethod2;
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
public UserDTO(User user) {
|
||||
this.id = user.getId();
|
||||
this.email = user.getEmail();
|
||||
this.subjectSet = user.getSubjectSet();
|
||||
this.notificationType = user.getNotificationMethod();
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public Set<Subject> getSubjectSet() {
|
||||
return subjectSet;
|
||||
}
|
||||
|
||||
public void setSubjectSet(Set<Subject> subjectSet) {
|
||||
this.subjectSet = subjectSet;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
public class UserLoginDTO {
|
||||
|
||||
private String email;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter // ← add class-level getter
|
||||
@Entity
|
||||
@Table(
|
||||
name = "device_tokens",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = "fcm_token")
|
||||
}
|
||||
)
|
||||
public class DeviceToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
@Setter
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Setter
|
||||
@Column(name = "fcm_token", nullable = false)
|
||||
private String fcmToken;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,5 @@ package dev.ksan.etfoglasiserver.model;
|
||||
public enum NotificationMethod {
|
||||
|
||||
NO_NOTIFICATION,
|
||||
EMAIL,
|
||||
PUSH_NOTIFICATION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterTokenRequest {
|
||||
private String token;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -9,13 +11,18 @@ import java.util.UUID;
|
||||
@Table(name = "subjects")
|
||||
public class Subject {
|
||||
|
||||
@Getter
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
@Column(columnDefinition = "uuid")
|
||||
private UUID id;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Column private long code;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@@ -26,23 +33,4 @@ public class Subject {
|
||||
public Subject(){
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(long code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -18,9 +20,11 @@ public class SubjectAlt {
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
@Setter
|
||||
@Column(nullable = false)
|
||||
String name;
|
||||
|
||||
@Setter
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(nullable = false)
|
||||
private Subject subject;
|
||||
@@ -32,22 +36,15 @@ public class SubjectAlt {
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public Subject getSubject() {
|
||||
return subject;
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
public void setSubject(Subject subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package dev.ksan.etfoglasiserver.model;
|
||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -24,18 +26,40 @@ public class User {
|
||||
nullable = false,
|
||||
updatable = false,
|
||||
insertable = false,
|
||||
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||
)
|
||||
private LocalDateTime regTime;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "notification_type", nullable = false)
|
||||
private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION;
|
||||
private NotificationMethod notificationType =
|
||||
NotificationMethod.PUSH_NOTIFICATION;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "subject_id"))
|
||||
@JoinTable(
|
||||
name = "user_subject",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "subject_id")
|
||||
)
|
||||
private Set<Subject> subjectSet;
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "user",
|
||||
cascade = CascadeType.ALL,
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
private List<DeviceToken> deviceTokens = new ArrayList<>();
|
||||
|
||||
public void addDeviceToken(DeviceToken token) {
|
||||
deviceTokens.add(token);
|
||||
token.setUser(this);
|
||||
}
|
||||
|
||||
public void removeDeviceToken(DeviceToken token) {
|
||||
deviceTokens.remove(token);
|
||||
token.setUser(null);
|
||||
}
|
||||
public User() {}
|
||||
|
||||
public User(UserCreationDTO user) {
|
||||
@@ -89,4 +113,5 @@ public class User {
|
||||
public NotificationMethod getNotificationMethod() {
|
||||
return notificationType;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.ksan.etfoglasiserver.repository;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.Entry;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DeviceTokenRepo extends JpaRepository<DeviceToken, UUID> {
|
||||
|
||||
List<DeviceToken> findByUserId(UUID userId);
|
||||
|
||||
Optional<DeviceToken> findByFcmToken(String fcmToken);
|
||||
|
||||
void deleteByFcmToken(String fcmToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
|
||||
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceTokenService {
|
||||
|
||||
private final DeviceTokenRepo deviceTokenRepo;
|
||||
private final UserRepo userRepo;
|
||||
|
||||
public void registerToken(String email, String token) {
|
||||
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
DeviceToken deviceToken = deviceTokenRepo
|
||||
.findByFcmToken(token)
|
||||
.orElse(new DeviceToken());
|
||||
|
||||
deviceToken.setUser(user);
|
||||
deviceToken.setFcmToken(token);
|
||||
|
||||
deviceTokenRepo.save(deviceToken);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public class EntryService {
|
||||
Entry newEntry = new Entry(entry);
|
||||
newEntry.setSubject(subject);
|
||||
entryRepo.save(newEntry);
|
||||
|
||||
subjectService.notifyAsync(newEntry);
|
||||
}
|
||||
|
||||
public void addEntry(Entry entry) {
|
||||
@@ -63,7 +65,7 @@ public class EntryService {
|
||||
System.out.println("Duplicate");
|
||||
return;
|
||||
}
|
||||
subjectService.notify(entry);
|
||||
subjectService.notifyAsync(entry);
|
||||
}
|
||||
|
||||
public void updateEntry(EntryDTO entry) {
|
||||
|
||||
@@ -12,24 +12,17 @@ import javax.crypto.SecretKey;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class JWTService {
|
||||
|
||||
//TODO add persistent token env variable
|
||||
private String secretKey = "";
|
||||
@Value("${jwt.secret}")
|
||||
private String secretKey;
|
||||
|
||||
|
||||
private JWTService() {
|
||||
try {
|
||||
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
|
||||
SecretKey secKey = keyGen.generateKey();
|
||||
secretKey = Base64.getEncoder().encodeToString(secKey.getEncoded());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateToken(String email) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import com.google.firebase.messaging.*;
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationService {
|
||||
|
||||
private final DeviceTokenRepo deviceTokenRepo;
|
||||
private final FirebaseMessaging firebaseMessaging;
|
||||
|
||||
public void sendToUser(User user, String title, String body)
|
||||
throws FirebaseMessagingException {
|
||||
|
||||
List<DeviceToken> tokens =
|
||||
deviceTokenRepo.findByUserId(user.getId());
|
||||
|
||||
for (DeviceToken token : tokens) {
|
||||
|
||||
Message message = Message.builder()
|
||||
.setToken(token.getFcmToken())
|
||||
.putData("title", title)
|
||||
.putData("body", body)
|
||||
.build();
|
||||
|
||||
try {
|
||||
firebaseMessaging.send(message);
|
||||
} catch (FirebaseMessagingException ex) {
|
||||
|
||||
if (ex.getMessagingErrorCode()
|
||||
== MessagingErrorCode.UNREGISTERED) {
|
||||
deviceTokenRepo.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingException;
|
||||
import dev.ksan.etfoglasiserver.model.Entry;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
@@ -8,22 +9,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
||||
import org.simplejavamail.api.mailer.Mailer;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SubjectService {
|
||||
|
||||
@Autowired private SubjectRepo subjectRepo;
|
||||
|
||||
@Autowired
|
||||
private UserRepo userRepo;
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private MailService mailService;
|
||||
|
||||
|
||||
public List<Subject> getSubjects() {
|
||||
return subjectRepo.findAll();
|
||||
@@ -67,29 +68,31 @@ public class SubjectService {
|
||||
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
|
||||
}
|
||||
|
||||
public void notify(Entry entry) {
|
||||
List<User> users = userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||
|
||||
//TODO move this and make an EventListener for this
|
||||
|
||||
@Async
|
||||
public void notifyAsync(Entry entry) {
|
||||
|
||||
List<User> users =
|
||||
userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||
|
||||
if (users == null || users.isEmpty()) return;
|
||||
|
||||
for (User user : users) {
|
||||
if(user.getNotificationMethod() == NotificationMethod.EMAIL)
|
||||
this.sendEmailNotification(entry, user);
|
||||
if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION)
|
||||
this.sendPushNotification(entry, user);
|
||||
System.out.println(user.getEmail());
|
||||
|
||||
if (user.getNotificationMethod() != NotificationMethod.PUSH_NOTIFICATION)
|
||||
continue;
|
||||
|
||||
try{
|
||||
|
||||
notificationService.sendToUser(user, entry.getTitle(), entry.getParagraph());
|
||||
}catch (FirebaseMessagingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void sendEmailNotification(Entry entry,User user) {
|
||||
|
||||
mailService.sendEmail(user.getEmail(),entry.getTitle(),entry.getParagraph());
|
||||
}
|
||||
|
||||
//todo
|
||||
public void sendPushNotification(Entry entry,User user) {
|
||||
System.out.println("Sending push notification");
|
||||
}
|
||||
|
||||
public long count() {
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
||||
@@ -23,6 +24,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -61,8 +63,8 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
public UserDTO updateUser(UserCreationDTO user) {
|
||||
Optional<User> existingUserOpt = userRepo.findByEmail(user.getEmail());
|
||||
public UserDTO updateUser(String email, UserCreationDTO user) {
|
||||
Optional<User> existingUserOpt = userRepo.findByEmail(email);
|
||||
|
||||
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
|
||||
throw new RuntimeException("Email taken");
|
||||
@@ -96,8 +98,12 @@ public class UserService {
|
||||
} else throw new RuntimeException("User not found");
|
||||
}
|
||||
|
||||
public void deleteUser(UUID userId) {
|
||||
userRepo.deleteById(userId);
|
||||
public void deleteUser(String email) {
|
||||
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
user.getSubjectSet().clear();
|
||||
userRepo.save(user);
|
||||
userRepo.delete(user);
|
||||
}
|
||||
|
||||
public UserDTO register(UserCreationDTO user) {
|
||||
@@ -144,19 +150,18 @@ public class UserService {
|
||||
|
||||
public JwtResponseDTO verify(UserLoginDTO user) {
|
||||
|
||||
Authentication authentication = authManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword())
|
||||
authManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.getEmail(),
|
||||
user.getPassword()
|
||||
)
|
||||
);
|
||||
|
||||
if (!authentication.isAuthenticated()) {
|
||||
throw new BadCredentialsException("Invalid username or password");
|
||||
}
|
||||
User foundUser = userRepo.findByEmail(user.getEmail())
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
String token = jwtService.generateToken(user.getEmail());
|
||||
|
||||
User foundUser = userRepo.findByEmail(user.getEmail())
|
||||
.orElseThrow(() -> new RuntimeException("User not found after authentication"));
|
||||
|
||||
return new JwtResponseDTO(
|
||||
token,
|
||||
foundUser.getEmail(),
|
||||
@@ -170,9 +175,9 @@ public class UserService {
|
||||
}
|
||||
|
||||
|
||||
public UserDTO addSubject(UUID userId, UUID subjectId) {
|
||||
public UserDTO addSubject(String email, UUID subjectId) {
|
||||
|
||||
User user = userRepo.findById(userId)
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
Subject subject = subjectRepo.findById(subjectId)
|
||||
@@ -185,9 +190,9 @@ public class UserService {
|
||||
return getUserDTO(user);
|
||||
}
|
||||
|
||||
public UserDTO removeSubject(UUID userId, UUID subjectId) {
|
||||
public UserDTO removeSubject(String email, UUID subjectId) {
|
||||
|
||||
User user = userRepo.findById(userId)
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
Subject subject = subjectRepo.findById(subjectId)
|
||||
@@ -200,4 +205,11 @@ public class UserService {
|
||||
|
||||
return getUserDTO(user);
|
||||
}
|
||||
|
||||
public void updateNotificationType(String email, NotificationMethod notificationType) {
|
||||
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
user.setNotificationMethod(notificationType);
|
||||
userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
spring.application.name=etfoglasi-server
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
|
||||
spring.datasource.username=test
|
||||
spring.datasource.password=test
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
spring.application.name=
|
||||
|
||||
spring.datasource.url=
|
||||
spring.datasource.username=
|
||||
spring.datasource.password=
|
||||
|
||||
# maybe use > openssl rand -base64 32
|
||||
jwt.secret=
|
||||
@@ -1,13 +0,0 @@
|
||||
package dev.ksan.etfoglasiserver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class EtfoglasiServerApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.ksan.etfoglasiserver.controller;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
|
||||
import dev.ksan.etfoglasiserver.service.DeviceTokenService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DeviceTokenControllerTest {
|
||||
|
||||
@Mock
|
||||
DeviceTokenService deviceTokenService;
|
||||
|
||||
@InjectMocks
|
||||
DeviceTokenController controller;
|
||||
|
||||
@Test
|
||||
void register_savesNewToken() {
|
||||
|
||||
UserDetails principal = mock(UserDetails.class);
|
||||
when(principal.getUsername()).thenReturn("test@test.com");
|
||||
|
||||
RegisterTokenRequest req = new RegisterTokenRequest();
|
||||
req.setToken("fcm-123");
|
||||
|
||||
ResponseEntity<Void> res = controller.register(req, principal);
|
||||
|
||||
assertEquals(204, res.getStatusCode().value());
|
||||
|
||||
verify(deviceTokenService).registerToken(
|
||||
"test@test.com",
|
||||
"fcm-123"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package dev.ksan.etfoglasiserver.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import dev.ksan.etfoglasiserver.config.SecurityConfig;
|
||||
import dev.ksan.etfoglasiserver.model.Entry;
|
||||
import dev.ksan.etfoglasiserver.service.EntryService;
|
||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||
import dev.ksan.etfoglasiserver.util.SubjectLoader;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(EntryController.class)
|
||||
@Import(SecurityConfig.class)
|
||||
class EntryControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
@MockitoBean
|
||||
EntryService entryService;
|
||||
|
||||
@MockitoBean
|
||||
JWTService jwtService;
|
||||
|
||||
|
||||
@MockitoBean
|
||||
MyUserDetailsService userDetailsService;
|
||||
|
||||
@MockitoBean
|
||||
SubjectLoader subjectLoader;
|
||||
|
||||
|
||||
@Test
|
||||
void getEntries_noParams_returns200() throws Exception {
|
||||
when(entryService.getEntries(null, null, 0))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mvc.perform(get("/api/entries"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEntries_withSubjectId_parsesUUIDCorrectly() throws Exception {
|
||||
UUID subjectId = UUID.randomUUID();
|
||||
when(entryService.getEntries(eq(subjectId), isNull(), eq(0)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mvc.perform(get("/api/entries")
|
||||
.param("subjectId", subjectId.toString())
|
||||
.param("page", "0"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(entryService).getEntries(eq(subjectId), isNull(), eq(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEntries_withGroupName_passesGroupNameToService() throws Exception {
|
||||
when(entryService.getEntries(isNull(), eq("Physics"), eq(0)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mvc.perform(get("/api/entries").param("groupName", "Physics"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(entryService).getEntries(isNull(), eq("Physics"), eq(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEntries_invalidUUID_returns400() throws Exception {
|
||||
mvc.perform(get("/api/entries").param("subjectId", "not-a-uuid"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void getGroups_returnsList() throws Exception {
|
||||
when(entryService.getAllGroups()).thenReturn(List.of("Math", "Physics", "CS"));
|
||||
|
||||
mvc.perform(get("/api/groups"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(3))
|
||||
.andExpect(jsonPath("$[0]").value("Math"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGroups_empty_returns200WithEmptyArray() throws Exception {
|
||||
when(entryService.getAllGroups()).thenReturn(List.of());
|
||||
|
||||
mvc.perform(get("/api/groups"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(0));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getEntry_existingId_returns200() throws Exception {
|
||||
Entry entry = new Entry();
|
||||
entry.setTitle("Test Entry");
|
||||
|
||||
when(entryService.getEntryById("entry-1")).thenReturn(entry);
|
||||
|
||||
mvc.perform(get("/api/entries/entry-1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Test Entry"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEntry_unauthenticated_returns401or403() throws Exception {
|
||||
mvc.perform(get("/api/entries/entry-1"))
|
||||
.andExpect(status().is(anyOf(equalTo(401), equalTo(403))));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void deleteEntry_authenticated_returns200() throws Exception {
|
||||
doNothing().when(entryService).deleteEntry("entry-1");
|
||||
|
||||
mvc.perform(delete("/api/entries/entry-1"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteEntry_unauthenticated_returns401or403() throws Exception {
|
||||
mvc.perform(delete("/api/entries/entry-1"))
|
||||
.andExpect(status().is(anyOf(equalTo(401), equalTo(403))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package dev.ksan.etfoglasiserver.controller;
|
||||
|
||||
import dev.ksan.etfoglasiserver.config.SecurityConfig;
|
||||
import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||
import dev.ksan.etfoglasiserver.service.UserService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import dev.ksan.etfoglasiserver.util.SubjectLoader;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.anyOf;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(UserController.class)
|
||||
@Import(SecurityConfig.class)
|
||||
class UserControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
ObjectMapper objectMapper;
|
||||
|
||||
@MockitoBean
|
||||
SubjectLoader subjectLoader;
|
||||
|
||||
@MockitoBean
|
||||
UserService userService;
|
||||
|
||||
@MockitoBean
|
||||
JWTService jwtService;
|
||||
|
||||
@MockitoBean
|
||||
MyUserDetailsService userDetailsService;
|
||||
|
||||
private static final String EMAIL = "test@example.com";
|
||||
|
||||
@Test
|
||||
void login_validCredentials_returns200WithToken() throws Exception {
|
||||
JwtResponseDTO response = new JwtResponseDTO("jwt-token", EMAIL, "user-id", "OK");
|
||||
when(userService.verify(any(UserLoginDTO.class))).thenReturn(response);
|
||||
|
||||
mvc.perform(post("/api/login")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"correct"}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").value("jwt-token"))
|
||||
.andExpect(jsonPath("$.email").value(EMAIL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_badCredentials_returns400() throws Exception {
|
||||
when(userService.verify(any()))
|
||||
.thenThrow(new RuntimeException("bad credentials"));
|
||||
|
||||
mvc.perform(post("/api/login")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"wrong"}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_nullResponse_returns400() throws Exception {
|
||||
when(userService.verify(any())).thenReturn(null);
|
||||
|
||||
mvc.perform(post("/api/login")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new UserLoginDTO("x@x.com", "p"))))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_newUser_returns201() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail(EMAIL);
|
||||
|
||||
when(userService.register(any()))
|
||||
.thenReturn(new UserDTO(user));
|
||||
|
||||
mvc.perform(post("/api/register")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("""
|
||||
{"email":"test@example.com","password":"pass123"}
|
||||
"""))
|
||||
.andExpect(status().isCreated());
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_duplicateEmail_returns400() throws Exception {
|
||||
when(userService.register(any()))
|
||||
.thenThrow(new RuntimeException("Email already exists"));
|
||||
|
||||
mvc.perform(post("/api/register")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new UserCreationDTO("dupe@example.com", "pass"))))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void getUser_authenticated_returns200() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail(EMAIL);
|
||||
|
||||
when(userService.getAccountByEmail(EMAIL))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mvc.perform(get("/api/users/me"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.email").value(EMAIL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUser_unauthenticated_returns401or403() throws Exception {
|
||||
mvc.perform(get("/api/users/me"))
|
||||
.andExpect(status().is(anyOf(equalTo(401), equalTo(403))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void getUser_notFound_returns404() throws Exception {
|
||||
when(userService.getAccountByEmail(EMAIL))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
mvc.perform(get("/api/users/me"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void updateUser_validBody_returns200() throws Exception {
|
||||
User user = new User();
|
||||
user.setEmail("new@example.com");
|
||||
|
||||
when(userService.updateUser(eq(EMAIL), any()))
|
||||
.thenReturn(new UserDTO(user));
|
||||
|
||||
mvc.perform(put("/api/users/me")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(
|
||||
new UserCreationDTO("new@example.com", "newpass"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.email").value("new@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void addSubject_validId_returns200() throws Exception {
|
||||
UUID subjectId = UUID.randomUUID();
|
||||
|
||||
User user = new User();
|
||||
user.setEmail(EMAIL);
|
||||
|
||||
when(userService.addSubject(EMAIL, subjectId))
|
||||
.thenReturn(new UserDTO(user));
|
||||
|
||||
mvc.perform(put("/api/users/me/subjects/{id}", subjectId))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void removeSubject_validId_returns200() throws Exception {
|
||||
UUID subjectId = UUID.randomUUID();
|
||||
|
||||
User user = new User();
|
||||
user.setEmail(EMAIL);
|
||||
|
||||
when(userService.removeSubject(EMAIL, subjectId))
|
||||
.thenReturn(new UserDTO(user));
|
||||
|
||||
mvc.perform(delete("/api/users/me/subjects/{id}", subjectId))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = EMAIL)
|
||||
void deleteUser_authenticated_returns204() throws Exception {
|
||||
doNothing().when(userService).deleteUser(EMAIL);
|
||||
|
||||
mvc.perform(delete("/api/users/me"))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest
|
||||
@TestPropertySource(locations = "classpath:application.properties")
|
||||
class JWTServiceTest {
|
||||
|
||||
@Autowired
|
||||
JWTService jwtService;
|
||||
|
||||
private org.springframework.security.core.userdetails.UserDetails userDetails(String email) {
|
||||
return org.springframework.security.core.userdetails.User
|
||||
.withUsername(email)
|
||||
.password("irrelevant")
|
||||
.roles("USER")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateToken_extractEmail_returnsCorrectEmail() {
|
||||
String token = jwtService.generateToken("alice@example.com");
|
||||
|
||||
assertThat(jwtService.extractEmail(token)).isEqualTo("alice@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateToken_correctUser_returnsTrue() {
|
||||
String token = jwtService.generateToken("alice@example.com");
|
||||
|
||||
assertThat(jwtService.validateToken(token, userDetails("alice@example.com"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateToken_differentUser_returnsFalse() {
|
||||
String token = jwtService.generateToken("alice@example.com");
|
||||
|
||||
assertThat(jwtService.validateToken(token, userDetails("bob@example.com"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateToken_tamperedToken_throwsException() {
|
||||
String token = jwtService.generateToken("alice@example.com");
|
||||
String tampered = token.substring(0, token.length() - 4) + "XXXX";
|
||||
|
||||
assertThatThrownBy(() -> jwtService.validateToken(tampered, userDetails("alice@example.com")))
|
||||
.isInstanceOf(Exception.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.google.firebase.messaging.FirebaseMessagingException;
|
||||
import com.google.firebase.messaging.Message;
|
||||
import com.google.firebase.messaging.MessagingErrorCode;
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Mock
|
||||
DeviceTokenRepo deviceTokenRepo;
|
||||
@Mock
|
||||
FirebaseMessaging firebaseMessaging;
|
||||
@InjectMocks
|
||||
NotificationService notificationService;
|
||||
|
||||
@Test
|
||||
void sendToUser_sendsMessageForEachToken() throws FirebaseMessagingException {
|
||||
User user = new User();
|
||||
DeviceToken t1 = new DeviceToken(); t1.setFcmToken("token-1");
|
||||
DeviceToken t2 = new DeviceToken(); t2.setFcmToken("token-2");
|
||||
|
||||
when(deviceTokenRepo.findByUserId(user.getId()))
|
||||
.thenReturn(List.of(t1, t2));
|
||||
when(firebaseMessaging.send(any())).thenReturn("msg-id");
|
||||
|
||||
notificationService.sendToUser(user, "Hello", "World");
|
||||
|
||||
verify(firebaseMessaging, times(2)).send(any(Message.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendToUser_deletesStaleToken_whenUnregistered() throws FirebaseMessagingException {
|
||||
User user = new User();
|
||||
DeviceToken token = new DeviceToken();
|
||||
token.setFcmToken("stale-token");
|
||||
|
||||
when(deviceTokenRepo.findByUserId(user.getId()))
|
||||
.thenReturn(List.of(token));
|
||||
|
||||
FirebaseMessagingException ex = mock(FirebaseMessagingException.class);
|
||||
when(ex.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED);
|
||||
when(firebaseMessaging.send(any())).thenThrow(ex);
|
||||
|
||||
notificationService.sendToUser(user, "Hello", "World");
|
||||
|
||||
verify(deviceTokenRepo).delete(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendToUser_doesNothing_whenUserHasNoTokens() throws FirebaseMessagingException {
|
||||
User user = new User();
|
||||
when(deviceTokenRepo.findByUserId(user.getId())).thenReturn(List.of());
|
||||
|
||||
notificationService.sendToUser(user, "Hello", "World");
|
||||
|
||||
verify(firebaseMessaging, never()).send(any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
spring.datasource.url=jdbc:h2:mem:etfo-db;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
spring.datasource.username=test
|
||||
spring.datasource.password=test
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||
firebase.enabled=false
|
||||
jwt.secret=testkeynekikaojer123451231231231231231231231=
|
||||
+9
-6
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "etfoglasi-frontend",
|
||||
"slug": "etfoglasi-frontend",
|
||||
"name": "etfoglasi",
|
||||
"slug": "etfoglasi",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "etfoglasifrontend",
|
||||
"scheme": "etfoglasi",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
@@ -19,7 +20,8 @@
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "dev.ksan.etfoglasi"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
@@ -39,7 +41,9 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"@react-native-firebase/app",
|
||||
"@react-native-firebase/messaging"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
@@ -47,4 +51,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,62 @@
|
||||
import "../globals.css";
|
||||
import { Stack } from "expo-router";
|
||||
import {AuthProvider} from "@/context/AuthContext";
|
||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import {
|
||||
getMessaging,
|
||||
onMessage,
|
||||
onNotificationOpenedApp,
|
||||
getInitialNotification,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
function NotificationSetup() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !user) return;
|
||||
|
||||
registerDeviceToken(user.token);
|
||||
|
||||
const messaging = getMessaging();
|
||||
|
||||
const unsubRefresh = listenForTokenRefresh(user.token);
|
||||
const unsubForeground = onMessage(messaging, async (msg) => {
|
||||
console.log('Foreground notification:', msg);
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: msg.notification?.title ?? 'New Notification',
|
||||
position: 'top',
|
||||
visibilityTime: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
onNotificationOpenedApp(messaging, (msg) => {
|
||||
console.log('Opened from background:', msg);
|
||||
});
|
||||
|
||||
getInitialNotification(messaging).then((msg) => {
|
||||
if (msg) console.log('Opened from quit state:', msg);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubRefresh();
|
||||
unsubForeground();
|
||||
};
|
||||
}, [user, loading]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NotificationSetup />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
<Toast />
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
authApi,
|
||||
|
||||
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
||||
LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
|
||||
} from "@/services/api";
|
||||
|
||||
type User = {
|
||||
@@ -11,6 +11,7 @@ type User = {
|
||||
email: string;
|
||||
token: string;
|
||||
subscribedSubjectIds: string[];
|
||||
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
@@ -23,6 +24,7 @@ type AuthContextValue = {
|
||||
unsubscribe: (subjectId: string) => Promise<void>;
|
||||
isSubscribed: (subjectId: string) => boolean;
|
||||
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
||||
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -43,17 +45,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(u);
|
||||
};
|
||||
|
||||
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
||||
if (!user) return;
|
||||
await userApi.updateNotificationType(type, user.token);
|
||||
await persist({ ...user, notificationType: type });
|
||||
};
|
||||
|
||||
const login = async (payload: LoginPayload) => {
|
||||
const res = await authApi.login(payload);
|
||||
|
||||
// Gracefully fetch subscriptions — don't block login if this fails
|
||||
let subscribedSubjectIds: string[] = [];
|
||||
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
|
||||
|
||||
if (res.userId) {
|
||||
try {
|
||||
const dto = await authApi.getUser(res.userId, res.token);
|
||||
const dto = await authApi.getUser(res.token);
|
||||
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
||||
} catch {
|
||||
// aaaa
|
||||
notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
|
||||
} catch (e) {
|
||||
console.error('Failed to load subscriptions on login:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +71,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email: res.email,
|
||||
token: res.token,
|
||||
subscribedSubjectIds,
|
||||
notificationType,
|
||||
});
|
||||
};
|
||||
|
||||
const register = async (payload: RegisterPayload) => {
|
||||
await authApi.register(payload);
|
||||
await login({ email: payload.email, password: payload.password });
|
||||
await login({
|
||||
email: payload.email.trim(),
|
||||
password: payload.password.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
||||
@@ -91,7 +104,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const subscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token);
|
||||
const dto = await subscriptionsApi.subscribe( subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
||||
@@ -101,7 +114,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const unsubscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token);
|
||||
const dto = await subscriptionsApi.unsubscribe( subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
||||
@@ -113,7 +126,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser, updateNotificationType }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
+3
-2
@@ -15,8 +15,9 @@ import {enableScreens} from "react-native-screens";
|
||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||
import AuthGate from "@/screens/AuthGate";
|
||||
import {AuthProvider, useAuth} from "@/context/AuthContext";
|
||||
|
||||
|
||||
import messaging, {setBackgroundMessageHandler} from "@react-native-firebase/messaging";
|
||||
import {getMessaging} from "@firebase/messaging";
|
||||
messaging().setBackgroundMessageHandler(async () => {});
|
||||
// Must be called before any navigator renders
|
||||
enableScreens();
|
||||
|
||||
|
||||
Generated
+1325
-432
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "etfoglasi-frontend",
|
||||
"name": "etfoglasi",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-firebase/app": "^24.0.0",
|
||||
"@react-native-firebase/messaging": "^24.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
@@ -37,6 +39,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-tailwindcss": "^1.1.11",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwindcss": "^3.4.18"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, {useCallback, useState} from "react";
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import CollapsibleCategory from "../components/CollapsibleCategory";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
|
||||
const GROUPS = [
|
||||
@@ -40,6 +41,12 @@ export default function AllFeed() {
|
||||
setTimeout(() => setRefreshing(false), 800);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
handleRefresh();
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -25,13 +25,19 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const submit = async () => {
|
||||
|
||||
const trimmedEmail = email.trim();
|
||||
const trimmedPassword = password.trim();
|
||||
|
||||
|
||||
|
||||
setError("");
|
||||
if (!email || !password) { setError("Please fill in all fields."); return; }
|
||||
if (!trimmedEmail || !trimmedPassword) { setError("Please fill in all fields."); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "login") await login({ email, password });
|
||||
else await register({ email, password, name });
|
||||
if (mode === "login") await login({ email:trimmedEmail, password:trimmedPassword});
|
||||
else await register({ email: trimmedEmail,password:trimmedPassword , name });
|
||||
onSuccess?.();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Something went wrong.");
|
||||
|
||||
@@ -23,6 +23,8 @@ type SettingRowProps = {
|
||||
accent?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
||||
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
||||
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
const [showSubs, setShowSubs] = useState(false);
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
const { user, updateNotificationType } = useAuth();
|
||||
const [notifications, setNotifications] = useState(
|
||||
user?.notificationType === 'PUSH_NOTIFICATION'
|
||||
);
|
||||
const [digest, setDigest] = React.useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const handleNotificationToggle = async (value: boolean) => {
|
||||
setNotifications(value);
|
||||
try {
|
||||
await updateNotificationType(
|
||||
value ? 'PUSH_NOTIFICATION' : 'NO_NOTIFICATION'
|
||||
);
|
||||
} catch {
|
||||
setNotifications(!value); // revert on failure
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
>
|
||||
<SectionLabel title="Notifications" />
|
||||
<SettingRow
|
||||
icon="notifications-outline" label="Push notifications (TODO)"
|
||||
toggle toggleValue={notifications} onToggle={setNotifications}
|
||||
icon="notifications-outline"
|
||||
label="Push notifications"
|
||||
toggle
|
||||
toggleValue={notifications}
|
||||
onToggle={handleNotificationToggle}
|
||||
accent={dark ? "#E07B45" : "#C4622D"}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="mail-outline" label="Daily digest email (TODO)"
|
||||
toggle toggleValue={digest} onToggle={setDigest}
|
||||
icon="mail-outline"
|
||||
label="Weekly Digest email (TODO)"
|
||||
toggle
|
||||
toggleValue={digest}
|
||||
onToggle={setDigest}
|
||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||
/>
|
||||
|
||||
@@ -231,32 +253,21 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
||||
<EditProfile onClose={() => setShowEdit(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
||||
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
visible={showSubs}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowSubs(false)}
|
||||
>
|
||||
<Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
|
||||
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default function Profile() {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
|
||||
import ExpandableItem from "../components/ExpandableItem";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Entry, entriesApi } from "../services/api";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
type SortMode = "time" | "subject";
|
||||
|
||||
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.subscribedSubjectIds]);
|
||||
}, [user?.subscribedSubjectIds?.join(',')]);
|
||||
|
||||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||||
// Always re-fetch when tab gains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchFeed();
|
||||
}, [fetchFeed])
|
||||
);
|
||||
|
||||
// Filter then group
|
||||
const filtered = useMemo(() => {
|
||||
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
|
||||
<View className="items-center mt-16 px-8">
|
||||
<Text className="text-4xl mb-3">🔍</Text>
|
||||
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
No results for "{query}"
|
||||
No results for "{query}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
|
||||
|
||||
|
||||
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
function authHeader(token?: string) {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -25,7 +25,13 @@ async function request<T>(
|
||||
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
console.log('Failed URL:', res.url);
|
||||
console.log('Status:', res.status);
|
||||
console.log('Response:', errorText);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -65,6 +71,7 @@ export type UserDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
subjectSet: Subject[];
|
||||
notificationType: NotificationType;
|
||||
|
||||
};
|
||||
|
||||
@@ -103,30 +110,46 @@ export type RegisterPayload = { email: string; password: string; name?: string }
|
||||
export const authApi = {
|
||||
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
|
||||
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body),
|
||||
|
||||
getUser: (token: string) =>
|
||||
get<UserDTO>("/users/me", token),
|
||||
|
||||
updateUser: (body: UserUpdateDTO, token: string) =>
|
||||
request<UserDTO>("PUT", "/users/me", token, body),
|
||||
|
||||
deleteUser: (token: string) =>
|
||||
del<void>("/users/me", token),
|
||||
};
|
||||
|
||||
|
||||
export const userApi = {
|
||||
updateNotificationType: (
|
||||
type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION',
|
||||
token: string
|
||||
) => post<void>('/users/me/notification-type', { notificationType: type }, token),
|
||||
};
|
||||
|
||||
export const subjectsApi = {
|
||||
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
||||
};
|
||||
|
||||
export const subscriptionsApi = {
|
||||
subscribe: (userId: string, subjectId: string, token: string) =>
|
||||
subscribe: (subjectId: string, token: string) =>
|
||||
put<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
{}, // body placeholder
|
||||
`/users/me/subjects/${subjectId}`,
|
||||
{},
|
||||
token
|
||||
),
|
||||
|
||||
unsubscribe: (userId: string, subjectId: string, token: string) =>
|
||||
unsubscribe: (subjectId: string, token: string) =>
|
||||
del<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
`/users/me/subjects/${subjectId}`,
|
||||
token
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const entriesApi = {
|
||||
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||
get<SpringPage<Entry>>("/entries", undefined, params),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
getMessaging,
|
||||
requestPermission,
|
||||
getToken,
|
||||
onTokenRefresh,
|
||||
AuthorizationStatus,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import { post } from '@/services/api';
|
||||
|
||||
export async function registerDeviceToken(authToken: string): Promise<void> {
|
||||
const messaging = getMessaging();
|
||||
const status = await requestPermission(messaging);
|
||||
const granted =
|
||||
status === AuthorizationStatus.AUTHORIZED ||
|
||||
status === AuthorizationStatus.PROVISIONAL;
|
||||
|
||||
if (!granted) return;
|
||||
|
||||
const fcmToken = await getToken(messaging);
|
||||
|
||||
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/device-tokens/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listenForTokenRefresh(authToken: string): () => void {
|
||||
return onTokenRefresh(getMessaging(), async (newToken) => {
|
||||
await post('/device-tokens/register', { token: newToken }, authToken);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user