17 Commits

Author SHA1 Message Date
ksan b53805ee3f testing new ci/cd
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Has been skipped
CI/CD / Backend Unit Tests (pull_request) Successful in 1m45s
CI/CD / Deploy (pull_request) Has been skipped
2026-06-10 18:03:56 +02:00
ksan 28bd6a8cbc fixed jwtservicetest not to enclude entire spring boot test
CI / backend-tests (push) Successful in 2m10s
2026-06-10 14:56:19 +02:00
ksan d598fdca1a removed cache
CI / backend-tests (push) Failing after 2m28s
2026-06-10 14:44:15 +02:00
ksan 09b6193077 added some tests and fixed server secret to be persistent
CI / backend-tests (push) Has been cancelled
2026-06-10 14:36:38 +02:00
ksan 61f9404a8a Temporarily remove failing integration tests
CI / test (push) Successful in 1m49s
2026-06-04 00:09:58 +02:00
ksan f53422fa8c added test application.properties
CI / test (push) Failing after 1m46s
2026-06-04 00:03:07 +02:00
ksan 86664921ae idk
CI / test (push) Failing after 2m2s
2026-06-03 23:55:37 +02:00
ksan 04f7375651 i think this will fix it? needed db for integretion testing
CI / test (push) Failing after 1m57s
2026-06-03 23:48:56 +02:00
ksan 629ae4c754 put the correct java version. i think?
CI / test (push) Failing after 2m8s
2026-06-03 23:39:35 +02:00
ksan 9bb40249ea forgot to add tests, and removed gradle cache
CI / test (push) Failing after 1m18s
2026-06-03 23:34:22 +02:00
ksan 4e5dee2e45 added some tests? and CI
CI / test (push) Failing after 11m59s
2026-06-03 23:25:08 +02:00
ksan 6559a9cd4b added push notifications 2026-06-03 19:13:56 +02:00
ksan a9278b8269 i think i added push notifications to backend but im going to sleep now and ill finish frontend later 2026-06-02 00:47:47 +02:00
ksan 4db77055ed commit before i break things 2026-06-01 23:40:58 +02:00
ksan 73c9bc1f14 updated readme 2026-05-31 21:21:13 +02:00
ksan 7d4ffee203 Remove bin directory from repo 2026-05-31 21:10:18 +02:00
ksan 6e8cb8d560 Remove bin from repository 2026-05-31 21:08:25 +02:00
87 changed files with 2931 additions and 768 deletions
+106
View File
@@ -0,0 +1,106 @@
name: CI/CD
on:
push:
branches:
- dev
- main
pull_request:
branches:
- main
env:
JAVA_VERSION: "21"
jobs:
backend-unit-tests:
name: Backend Unit Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew test
deploy:
name: Deploy
needs: backend-unit-tests
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Build boot jar
working-directory: backend
run: |
chmod +x gradlew
./gradlew bootJar
- name: Stage jar for Docker
working-directory: backend
run: |
BOOT_JAR=$(find build/libs -name "*.jar" ! -name "*-plain.jar" | head -n 1)
cp "$BOOT_JAR" app.jar
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.${{ secrets.DOMAIN }} \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build Docker image
run: |
docker build \
-t git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest \
backend/
- name: Push Docker image
run: |
docker push \
git.${{ secrets.DOMAIN }}/${{ secrets.REGISTRY_USER }}/etf-oglasi-server:latest
- name: Deploy to VPS
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/key
chmod 600 ~/.ssh/key
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
# Write secrets to VPS (only if they've changed or first deploy)
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"mkdir -p ~/programs/etf-oglasi-server/config"
echo "${{ secrets.FIREBASE_CREDENTIALS }}" | \
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cat > ~/programs/etf-oglasi-server/config/firebase.json"
ssh -i ~/.ssh/key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
+63 -61
View File
@@ -1,18 +1,8 @@
backend/.db/ ########################
backend/.env # GLOBAL (all projects)
backend/todo ########################
backend/.creds
backend/init/*
backend/*temp.java
backend/src/main/java/dev/ksan/etfoglasiserver/temp.java
todo
# Java / Gradle
backend/.gradle/
backend/build/
# IDEs # IDEs
.idea/ .idea/
@@ -21,7 +11,33 @@ backend/build/
*.ipr *.ipr
*.iws *.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
backend/src/main/resources/firebase.json
# Java / Eclipse / STS
.classpath .classpath
.project .project
.settings/ .settings/
@@ -34,54 +50,40 @@ nbbuild/
nbdist/ nbdist/
.nb-gradle/ .nb-gradle/
# Environment & secrets ########################
backend/.env # FRONTEND (React Native / Expo)
backend/.creds ########################
# Local database frontend/node_modules/
backend/.db/
## frontend frontend/google-services.json
# dependencies frontend/.expo/
node_modules/ 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
.expo/ frontend/expo-env.d.ts
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
+88
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
-4
View File
@@ -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
+17
View File
@@ -39,6 +39,19 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
implementation("io.jsonwebtoken:jjwt-api:0.13.0") implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl: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 { generateJava {
@@ -49,4 +62,8 @@ generateJava {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
jvmArgs += [
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('byte-buddy-agent') }}"
]
} }
+4 -4
View File
@@ -3,11 +3,11 @@ services:
image: 'postgres:16' image: 'postgres:16'
container_name: etfo-db container_name: etfo-db
environment: environment:
- 'POSTGRES_DB=etfo-db' - POSTGRES_DB=etfo-db
- 'POSTGRES_PASSWORD=test' - POSTGRES_PASSWORD=test
- 'POSTGRES_USER=test' - POSTGRES_USER=test
volumes: volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql - ./init.sql:/docker-entrypoint-initdb.d/init.sql
- ./.db:/var/lib/postgresql/data - ./.db:/var/lib/postgresql/data
ports: ports:
- '5432:5432' - 5432:5432
+27 -1
View File
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
CONSTRAINT subjects_pkey PRIMARY KEY (id) 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, user_id uuid NOT NULL,
subject_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) 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 ALTER TABLE IF EXISTS public.alt_subject
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id) ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
REFERENCES public.subjects (id) MATCH SIMPLE REFERENCES public.subjects (id) MATCH SIMPLE
@@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync
@SpringBootApplication @SpringBootApplication
public class EtfoglasiServerApplication { 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("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 token = null;
String username = null; String username = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) { if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7); token = authHeader.substring(7);
username = jwtService.extractEmail(token); 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.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@@ -36,16 +38,30 @@ public class EntryController {
} }
@GetMapping("/entries") @GetMapping("/entries")
public Page<Entry> getEntries( public Page<Entry> getEntries(
@RequestParam(required = false) String subjectId, @RequestParam(required = false) String subjectId,
@RequestParam(required = false) String groupName, @RequestParam(required = false) String groupName,
@RequestParam(defaultValue = "0") int page @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); return service.getEntries(parsedSubjectId, groupName, page);
} }
@PutMapping("/entries") @PutMapping("/entries")
public void updateEntry(@RequestBody EntryDTO entry) { public void updateEntry(@RequestBody EntryDTO entry) {
service.updateEntry(entry); service.updateEntry(entry);
@@ -23,13 +23,13 @@ public class SubjectAltController {
return service.getSubjectAlt(subjectId); return service.getSubjectAlt(subjectId);
} }
@PostMapping("/subjectalt") // @PostMapping("/subjectalt")
public void addSubject(@RequestBody SubjectAlt subject) { // public void addSubject(@RequestBody SubjectAlt subject) {
service.addSubjectAlt(subject); // service.addSubjectAlt(subject);
} // }
//
@DeleteMapping("/subjectalt/{subjectId}") // @DeleteMapping("/subjectalt/{subjectId}")
public void deleteSubject(@PathVariable UUID subjectId) { // public void deleteSubject(@PathVariable UUID subjectId) {
service.deleteSubjectAlt(subjectId); // service.deleteSubjectAlt(subjectId);
} // }
} }
@@ -22,18 +22,21 @@ public class SubjectController {
return service.getSubject(subjectId); return service.getSubject(subjectId);
} }
@PostMapping("/subjects") @PostMapping("/subjects")
public void addSubject(@RequestBody Subject subject) { public void addSubject(@RequestBody Subject subject) {
service.addSubject(subject); service.addSubject(subject);
} }
@PutMapping("/subjects") //TODO uncomment this if ever roles are added so only admin should modify this (didnt really feel like doing it rn)
public void updateSubject(@RequestBody Subject subject) { //
service.updateSubject(subject); // @PutMapping("/subjects")
} // public void updateSubject(@RequestBody Subject subject) {
// service.updateSubject(subject);
// }
@DeleteMapping("/subjects/{subjectId}") // @DeleteMapping("/subjects/{subjectId}")
public void deleteSubject(@PathVariable UUID subjectId) { // public void deleteSubject(@PathVariable UUID subjectId) {
service.deleteSubject(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.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.service.UserService; import dev.ksan.etfoglasiserver.service.UserService;
import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@@ -20,76 +24,106 @@ public class UserController {
@Autowired UserService service; @Autowired UserService service;
@GetMapping("users/{userId}") @GetMapping("/users/me")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID userId) { 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 ResponseEntity.ok(new UserDTO(user));
return new ResponseEntity<>(user, HttpStatus.OK);
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} }
@PutMapping("/users") @PutMapping("/users/me")
public ResponseEntity<UserDTO> updateUser(@RequestBody UserCreationDTO user) { public ResponseEntity<UserDTO> updateUser(
@RequestBody UserCreationDTO user,
Authentication authentication) {
UserDTO userUpdated = service.updateUser(user); String email = authentication.getName();
if (userUpdated != null) {
return new ResponseEntity<>(userUpdated, HttpStatus.OK); UserDTO userUpdated = service.updateUser(email, user);
}
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return ResponseEntity.ok(userUpdated);
} }
@PutMapping("/users/{userId}/subjects/{subjectId}")
public ResponseEntity<UserDTO> addSubject(
@PathVariable UUID userId,
@PathVariable UUID subjectId
) {
UserDTO updatedUser = service.addSubject(userId, subjectId);
return ResponseEntity.ok(updatedUser); @PostMapping("/users/me/notification-type")
} public ResponseEntity<?> updateNotificationType(
@RequestBody Map<String, String> request,
Authentication authentication) {
@DeleteMapping("/users/{userId}/subjects/{subjectId}") String email = authentication.getName();
public ResponseEntity<UserDTO> removeSubject( NotificationMethod type = NotificationMethod.valueOf(request.get("notificationType"));
@PathVariable UUID userId, service.updateNotificationType(email, type);
@PathVariable UUID subjectId
) {
UserDTO updatedUser = service.removeSubject(userId, subjectId); return ResponseEntity.ok(Map.of("status", "updated"));
}
return ResponseEntity.ok(updatedUser); @PutMapping("/users/me/subjects/{subjectId}")
} public ResponseEntity<UserDTO> addSubject(
@PathVariable UUID subjectId,
Authentication authentication) {
String email = authentication.getName();
UserDTO updatedUser = service.addSubject(email, subjectId);
return ResponseEntity.ok(updatedUser);
}
@DeleteMapping("/users/me/subjects/{subjectId}")
public ResponseEntity<UserDTO> removeSubject(
@PathVariable UUID subjectId,
Authentication authentication) {
String email = authentication.getName();
UserDTO updatedUser = service.removeSubject(email, subjectId);
return ResponseEntity.ok(updatedUser);
}
// not the best, should fix this later
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) { public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
JwtResponseDTO userVer = service.verify(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);
}
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} }
// not the best, should fix this later too
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) { public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
UserDTO newUser = service.register(user); try {
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED); 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); 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) { @DeleteMapping("/users/me")
NotificationMethod method = NotificationMethod.valueOf(user.getNotification()); public ResponseEntity<Void> deleteUser(Authentication authentication) {
user.setNotificationMethod(method);
service.addUser(user); String email = authentication.getName();
service.deleteUser(email);
return ResponseEntity.noContent().build();
} }
*/
} }
@@ -12,6 +12,11 @@ public class UserCreationDTO {
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
private String notification; private String notification;
public UserCreationDTO(String mail, String password) {
this.email = mail;
this.password = password;
}
public String getEmail() { public String getEmail() {
return email; return email;
} }
@@ -2,13 +2,26 @@ package dev.ksan.etfoglasiserver.dto;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; 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.Set;
import java.util.UUID; import java.util.UUID;
public class UserDTO { public class UserDTO {
@Setter
@Getter
private UUID id; private UUID id;
@Setter
@Getter
private String email; private String email;
@Setter
@Getter
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
@Getter
@Setter
private NotificationMethod notificationType;
public UserDTO() {} public UserDTO() {}
@@ -17,31 +30,17 @@ public class UserDTO {
this.id = id; this.id = id;
this.email = email; this.email = email;
this.subjectSet = subjectSet; this.subjectSet = subjectSet;
this.notificationType = notificationMethod2;
} }
public UUID getId() { public UserDTO(User user) {
return id; 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; package dev.ksan.etfoglasiserver.dto;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class UserLoginDTO { public class UserLoginDTO {
private String email; 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 { public enum NotificationMethod {
NO_NOTIFICATION, NO_NOTIFICATION,
EMAIL,
PUSH_NOTIFICATION 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; package dev.ksan.etfoglasiserver.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -9,13 +11,18 @@ import java.util.UUID;
@Table(name = "subjects") @Table(name = "subjects")
public class Subject { public class Subject {
@Getter
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@Column(columnDefinition = "uuid") @Column(columnDefinition = "uuid")
private UUID id; private UUID id;
@Getter
@Setter
@Column private long code; @Column private long code;
@Getter
@Setter
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@@ -26,23 +33,4 @@ public class Subject {
public 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.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Setter;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -18,9 +20,11 @@ public class SubjectAlt {
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
private UUID id; private UUID id;
@Setter
@Column(nullable = false) @Column(nullable = false)
String name; String name;
@Setter
@ManyToOne(fetch = FetchType.EAGER) @ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private Subject subject; private Subject subject;
@@ -32,22 +36,15 @@ public class SubjectAlt {
} }
public UUID getId() { public UUID getId() {
return id; return this.id;
} }
public String getName() { public String getName() {
return name; return this.name;
}
public void setName(String name) {
this.name = name;
} }
public Subject getSubject() { 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 dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -21,21 +23,43 @@ public class User {
private String password; private String password;
@Column( @Column(
nullable = false, nullable = false,
updatable = false, updatable = false,
insertable = false, insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
)
private LocalDateTime regTime; private LocalDateTime regTime;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "notification_type", nullable = false) @Column(name = "notification_type", nullable = false)
private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION; private NotificationMethod notificationType =
NotificationMethod.PUSH_NOTIFICATION;
@ManyToMany(fetch = FetchType.LAZY ) @ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"), @JoinTable(
inverseJoinColumns = @JoinColumn(name = "subject_id")) name = "user_subject",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "subject_id")
)
private Set<Subject> subjectSet; 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() {}
public User(UserCreationDTO user) { public User(UserCreationDTO user) {
@@ -89,4 +113,5 @@ public class User {
public NotificationMethod getNotificationMethod() { public NotificationMethod getNotificationMethod() {
return notificationType; 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); Entry newEntry = new Entry(entry);
newEntry.setSubject(subject); newEntry.setSubject(subject);
entryRepo.save(newEntry); entryRepo.save(newEntry);
subjectService.notifyAsync(newEntry);
} }
public void addEntry(Entry entry) { public void addEntry(Entry entry) {
@@ -63,7 +65,7 @@ public class EntryService {
System.out.println("Duplicate"); System.out.println("Duplicate");
return; return;
} }
subjectService.notify(entry); subjectService.notifyAsync(entry);
} }
public void updateEntry(EntryDTO entry) { public void updateEntry(EntryDTO entry) {
@@ -12,24 +12,17 @@ import javax.crypto.SecretKey;
import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class JWTService { public class JWTService {
//TODO add persistent token env variable @Value("${jwt.secret}")
private String secretKey = ""; 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) { public String generateToken(String email) {
Map<String, Object> claims = new HashMap<>(); 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; package dev.ksan.etfoglasiserver.service;
import com.google.firebase.messaging.FirebaseMessagingException;
import dev.ksan.etfoglasiserver.model.Entry; import dev.ksan.etfoglasiserver.model.Entry;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
@@ -8,22 +9,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo;
import java.util.List; import java.util.List;
import java.util.UUID; 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.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class SubjectService { public class SubjectService {
@Autowired private SubjectRepo subjectRepo; @Autowired private SubjectRepo subjectRepo;
@Autowired @Autowired
private UserRepo userRepo; private NotificationService notificationService;
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private MailService mailService;
public List<Subject> getSubjects() { public List<Subject> getSubjects() {
return subjectRepo.findAll(); return subjectRepo.findAll();
@@ -67,32 +68,34 @@ public class SubjectService {
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found")); return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
} }
public void notify(Entry entry) {
List<User> users = userService.findUsersBySubjectId(entry.getSubject().getId());
if(users == null || users.isEmpty()) return; //TODO move this and make an EventListener for this
for(User user : users) {
if(user.getNotificationMethod() == NotificationMethod.EMAIL) @Async
this.sendEmailNotification(entry, user); public void notifyAsync(Entry entry) {
if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION)
this.sendPushNotification(entry, user); List<User> users =
System.out.println(user.getEmail()); userService.findUsersBySubjectId(entry.getSubject().getId());
if (users == null || users.isEmpty()) return;
for (User user : users) {
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()); public long count() {
return subjectRepo.count();
} }
//todo
public void sendPushNotification(Entry entry,User user) {
System.out.println("Sending push notification");
}
public long count() {
return subjectRepo.count();
}
} }
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import dev.ksan.etfoglasiserver.dto.UserDTO; import dev.ksan.etfoglasiserver.dto.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.repository.SubjectRepo; 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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service @Service
@Transactional @Transactional
@@ -61,8 +63,8 @@ public class UserService {
} }
} }
public UserDTO updateUser(UserCreationDTO user) { public UserDTO updateUser(String email, UserCreationDTO user) {
Optional<User> existingUserOpt = userRepo.findByEmail(user.getEmail()); Optional<User> existingUserOpt = userRepo.findByEmail(email);
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) { if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
throw new RuntimeException("Email taken"); throw new RuntimeException("Email taken");
@@ -96,8 +98,12 @@ public class UserService {
} else throw new RuntimeException("User not found"); } else throw new RuntimeException("User not found");
} }
public void deleteUser(UUID userId) { public void deleteUser(String email) {
userRepo.deleteById(userId); 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) { public UserDTO register(UserCreationDTO user) {
@@ -142,37 +148,36 @@ public class UserService {
throw new RuntimeException("Invalid email"); throw new RuntimeException("Invalid email");
} }
public JwtResponseDTO verify(UserLoginDTO user) { public JwtResponseDTO verify(UserLoginDTO user) {
Authentication authentication = authManager.authenticate( authManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword()) new UsernamePasswordAuthenticationToken(
); user.getEmail(),
user.getPassword()
)
);
if (!authentication.isAuthenticated()) { User foundUser = userRepo.findByEmail(user.getEmail())
throw new BadCredentialsException("Invalid username or password"); .orElseThrow(() -> new RuntimeException("User not found"));
}
String token = jwtService.generateToken(user.getEmail()); String token = jwtService.generateToken(user.getEmail());
User foundUser = userRepo.findByEmail(user.getEmail()) return new JwtResponseDTO(
.orElseThrow(() -> new RuntimeException("User not found after authentication")); token,
foundUser.getEmail(),
return new JwtResponseDTO( foundUser.getId().toString(),
token, HttpStatus.OK.toString()
foundUser.getEmail(), );
foundUser.getId().toString(), }
HttpStatus.OK.toString()
);
}
public Optional<User> getAccountByEmail(String email) { public Optional<User> getAccountByEmail(String email) {
return userRepo.findByEmail(email); return userRepo.findByEmail(email);
} }
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")); .orElseThrow(() -> new RuntimeException("User not found"));
Subject subject = subjectRepo.findById(subjectId) Subject subject = subjectRepo.findById(subjectId)
@@ -185,9 +190,9 @@ public class UserService {
return getUserDTO(user); 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")); .orElseThrow(() -> new RuntimeException("User not found"));
Subject subject = subjectRepo.findById(subjectId) Subject subject = subjectRepo.findById(subjectId)
@@ -200,4 +205,11 @@ public class UserService {
return getUserDTO(user); 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() {
}
}
@@ -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,62 @@
package dev.ksan.etfoglasiserver.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import java.lang.reflect.Field;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
class JWTServiceTest {
private JWTService jwtService;
private static final String TEST_SECRET =
"dGVzdHNlY3JldGtleXRoYXRpc2xvbmdlbm91Z2hmb3JibWFjc2hhMjU2dGVzdGtleQ==";
@BeforeEach
void setUp() throws Exception {
jwtService = new JWTService();
Field secret = JWTService.class.getDeclaredField("secretKey");
secret.setAccessible(true);
secret.set(jwtService, TEST_SECRET);
}
@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);
}
private org.springframework.security.core.userdetails.UserDetails userDetails(String email) {
return org.springframework.security.core.userdetails.User
.withUsername(email).password("x").roles("USER").build();
}
}
@@ -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=
+24
View File
@@ -0,0 +1,24 @@
name: CI - full
on:
pull_request:
branches: [main]
jobs:
backend-test:
uses: ./.gitea/workflows/backend-test.yaml
frontend-build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Build frontend
run: npx expo export --platform web
+9 -6
View File
@@ -1,17 +1,18 @@
{ {
"expo": { "expo": {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"slug": "etfoglasi-frontend", "slug": "etfoglasi",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "etfoglasifrontend", "scheme": "etfoglasi",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
@@ -19,7 +20,8 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "dev.ksan.etfoglasi"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -39,7 +41,9 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
] ],
"@react-native-firebase/app",
"@react-native-firebase/messaging"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
@@ -47,4 +51,3 @@
} }
} }
} }
+52 -3
View File
@@ -1,13 +1,62 @@
import "../globals.css"; import "../globals.css";
import { Stack } from "expo-router"; import { useEffect } from 'react';
import {AuthProvider} from "@/context/AuthContext"; import { Stack } from 'expo-router';
import {SafeAreaProvider} from "react-native-safe-area-context"; 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() { export default function RootLayout() {
return ( return (
<AuthProvider> <AuthProvider>
<SafeAreaProvider> <SafeAreaProvider>
<NotificationSetup />
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
<Toast />
</SafeAreaProvider> </SafeAreaProvider>
</AuthProvider> </AuthProvider>
); );
+23 -10
View File
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { import {
authApi, authApi,
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO, LoginPayload, RegisterPayload, subscriptionsApi, userApi, UserUpdateDTO,
} from "@/services/api"; } from "@/services/api";
type User = { type User = {
@@ -11,6 +11,7 @@ type User = {
email: string; email: string;
token: string; token: string;
subscribedSubjectIds: string[]; subscribedSubjectIds: string[];
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
}; };
type AuthContextValue = { type AuthContextValue = {
@@ -23,6 +24,7 @@ type AuthContextValue = {
unsubscribe: (subjectId: string) => Promise<void>; unsubscribe: (subjectId: string) => Promise<void>;
isSubscribed: (subjectId: string) => boolean; isSubscribed: (subjectId: string) => boolean;
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>; updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
@@ -43,17 +45,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(u); 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 login = async (payload: LoginPayload) => {
const res = await authApi.login(payload); const res = await authApi.login(payload);
// Gracefully fetch subscriptions — don't block login if this fails
let subscribedSubjectIds: string[] = []; let subscribedSubjectIds: string[] = [];
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
if (res.userId) { if (res.userId) {
try { try {
const dto = await authApi.getUser(res.userId, res.token); const dto = await authApi.getUser(res.token);
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? []; subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
} catch { notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
// aaaa } 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, email: res.email,
token: res.token, token: res.token,
subscribedSubjectIds, subscribedSubjectIds,
notificationType,
}); });
}; };
const register = async (payload: RegisterPayload) => { const register = async (payload: RegisterPayload) => {
await authApi.register(payload); 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) => { const updateUser = async (newEmail?: string, newPassword?: string) => {
@@ -91,7 +104,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const subscribe = async (subjectId: string) => { const subscribe = async (subjectId: string) => {
if (!user) return; if (!user) return;
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token); const dto = await subscriptionsApi.subscribe( subjectId, user.token);
const updated = { const updated = {
...user, ...user,
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId], 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) => { const unsubscribe = async (subjectId: string) => {
if (!user) return; if (!user) return;
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token); const dto = await subscriptionsApi.unsubscribe( subjectId, user.token);
const updated = { const updated = {
...user, ...user,
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId), 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; user?.subscribedSubjectIds.includes(subjectId) ?? false;
return ( 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} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+3 -2
View File
@@ -15,8 +15,9 @@ import {enableScreens} from "react-native-screens";
import {SafeAreaProvider} from "react-native-safe-area-context"; import {SafeAreaProvider} from "react-native-safe-area-context";
import AuthGate from "@/screens/AuthGate"; import AuthGate from "@/screens/AuthGate";
import {AuthProvider, useAuth} from "@/context/AuthContext"; 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 // Must be called before any navigator renders
enableScreens(); enableScreens();
+1325 -432
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,18 +1,20 @@
{ {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0", "@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/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -37,6 +39,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-tailwindcss": "^1.1.11", "react-native-tailwindcss": "^1.1.11",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.18" "tailwindcss": "^3.4.18"
+8 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, {useCallback, useState} from "react";
import { import {
View, Text, ScrollView, TouchableOpacity, View, Text, ScrollView, TouchableOpacity,
ActivityIndicator, useColorScheme, ActivityIndicator, useColorScheme,
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import SearchBar from "../components/SearchBar"; import SearchBar from "../components/SearchBar";
import CollapsibleCategory from "../components/CollapsibleCategory"; import CollapsibleCategory from "../components/CollapsibleCategory";
import {useFocusEffect} from "expo-router";
const GROUPS = [ const GROUPS = [
@@ -40,6 +41,12 @@ export default function AllFeed() {
setTimeout(() => setRefreshing(false), 800); setTimeout(() => setRefreshing(false), 800);
}; };
useFocusEffect(
useCallback(() => {
handleRefresh();
}, [])
);
return ( return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}> <SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */} {/* Header */}
+9 -3
View File
@@ -25,13 +25,19 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
const accent = dark ? "#E07B45" : "#C4622D"; const accent = dark ? "#E07B45" : "#C4622D";
const submit = async () => { const submit = async () => {
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
setError(""); setError("");
if (!email || !password) { setError("Please fill in all fields."); return; } if (!trimmedEmail || !trimmedPassword) { setError("Please fill in all fields."); return; }
setLoading(true); setLoading(true);
try { try {
if (mode === "login") await login({ email, password }); if (mode === "login") await login({ email:trimmedEmail, password:trimmedPassword});
else await register({ email, password, name }); else await register({ email: trimmedEmail,password:trimmedPassword , name });
onSuccess?.(); onSuccess?.();
} catch (e: any) { } catch (e: any) {
setError(e.message ?? "Something went wrong."); setError(e.message ?? "Something went wrong.");
+30 -19
View File
@@ -23,6 +23,8 @@ type SettingRowProps = {
accent?: string; accent?: string;
}; };
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) { function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278"); const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) { function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
const [showSubs, setShowSubs] = useState(false); const [showSubs, setShowSubs] = useState(false);
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user } = useAuth(); const { user, updateNotificationType } = useAuth();
const [notifications, setNotifications] = React.useState(true); const [notifications, setNotifications] = useState(
user?.notificationType === 'PUSH_NOTIFICATION'
);
const [digest, setDigest] = React.useState(false); const [digest, setDigest] = React.useState(false);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showHelp, setShowHelp] = 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 ( return (
<> <>
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
> >
<SectionLabel title="Notifications" /> <SectionLabel title="Notifications" />
<SettingRow <SettingRow
icon="notifications-outline" label="Push notifications (TODO)" icon="notifications-outline"
toggle toggleValue={notifications} onToggle={setNotifications} label="Push notifications"
toggle
toggleValue={notifications}
onToggle={handleNotificationToggle}
accent={dark ? "#E07B45" : "#C4622D"} accent={dark ? "#E07B45" : "#C4622D"}
/> />
<SettingRow <SettingRow
icon="mail-outline" label="Daily digest email (TODO)" icon="mail-outline"
toggle toggleValue={digest} onToggle={setDigest} label="Weekly Digest email (TODO)"
toggle
toggleValue={digest}
onToggle={setDigest}
accent={dark ? "#5E9EF4" : "#3B7DD8"} 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> <Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}> <Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
<EditProfile onClose={() => setShowEdit(false)} /> <EditProfile onClose={() => setShowEdit(false)} />
</Modal> </Modal>
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}> <Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
<HelpSupport onClose={() => setShowHelp(false)} /> <HelpSupport onClose={() => setShowHelp(false)} />
</Modal> </Modal>
<Modal <Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
visible={showSubs}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowSubs(false)}
>
<ManageSubscriptions onClose={() => setShowSubs(false)} /> <ManageSubscriptions onClose={() => setShowSubs(false)} />
</Modal> </Modal>
</> </>
); );
} }
export default function Profile() { export default function Profile() {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user, logout } = useAuth(); const { user, logout } = useAuth();
+9 -3
View File
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
import ExpandableItem from "../components/ExpandableItem"; import ExpandableItem from "../components/ExpandableItem";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Entry, entriesApi } from "../services/api"; import { Entry, entriesApi } from "../services/api";
import {useFocusEffect} from "expo-router";
type SortMode = "time" | "subject"; type SortMode = "time" | "subject";
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user?.subscribedSubjectIds]); }, [user?.subscribedSubjectIds?.join(',')]);
useEffect(() => { fetchFeed(); }, [fetchFeed]); // Always re-fetch when tab gains focus
useFocusEffect(
useCallback(() => {
fetchFeed();
}, [fetchFeed])
);
// Filter then group // Filter then group
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
<View className="items-center mt-16 px-8"> <View className="items-center mt-16 px-8">
<Text className="text-4xl mb-3">🔍</Text> <Text className="text-4xl mb-3">🔍</Text>
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}> <Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
No results for "{query}" No results for &#34;{query}&#34;
</Text> </Text>
</View> </View>
) : ( ) : (
+32 -9
View File
@@ -1,7 +1,7 @@
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
function authHeader(token?: string) { function authHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
@@ -25,7 +25,13 @@ async function request<T>(
headers: { "Content-Type": "application/json", ...authHeader(token) }, headers: { "Content-Type": "application/json", ...authHeader(token) },
...(body ? { body: JSON.stringify(body) } : {}), ...(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(); return res.json();
} }
@@ -65,6 +71,7 @@ export type UserDTO = {
id: string; id: string;
email: string; email: string;
subjectSet: Subject[]; subjectSet: Subject[];
notificationType: NotificationType;
}; };
@@ -103,30 +110,46 @@ export type RegisterPayload = { email: string; password: string; name?: string }
export const authApi = { export const authApi = {
login: (p: LoginPayload) => post<JwtResponse>("/login", p), login: (p: LoginPayload) => post<JwtResponse>("/login", p),
register: (p: RegisterPayload) => post<UserDTO>("/register", 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 = { export const subjectsApi = {
getAll: (token?: string) => get<Subject[]>("/subjects", token), getAll: (token?: string) => get<Subject[]>("/subjects", token),
}; };
export const subscriptionsApi = { export const subscriptionsApi = {
subscribe: (userId: string, subjectId: string, token: string) => subscribe: (subjectId: string, token: string) =>
put<UserDTO>( put<UserDTO>(
`/users/${userId}/subjects/${subjectId}`, `/users/me/subjects/${subjectId}`,
{}, // body placeholder {},
token token
), ),
unsubscribe: (userId: string, subjectId: string, token: string) => unsubscribe: (subjectId: string, token: string) =>
del<UserDTO>( del<UserDTO>(
`/users/${userId}/subjects/${subjectId}`, `/users/me/subjects/${subjectId}`,
token token
), ),
}; };
export const entriesApi = { export const entriesApi = {
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) => getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
get<SpringPage<Entry>>("/entries", undefined, params), get<SpringPage<Entry>>("/entries", undefined, params),
+35
View File
@@ -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);
});
}