Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3529f892c | |||
| 7d7e641f5b | |||
| 3afe5e2931 | |||
| 14725e180a | |||
| 30d2abd4c5 | |||
| ee4eff34c9 | |||
| 62a3b11f43 | |||
| 6f941e7842 | |||
| 8db2ecfefe | |||
| 4621c0d5ca | |||
| 35c40791e6 | |||
| b53805ee3f | |||
| 28bd6a8cbc | |||
| d598fdca1a | |||
| 09b6193077 | |||
| 61f9404a8a | |||
| f53422fa8c | |||
| 86664921ae | |||
| 04f7375651 | |||
| 629ae4c754 | |||
| 9bb40249ea | |||
| 4e5dee2e45 | |||
| 6559a9cd4b | |||
| a9278b8269 | |||
| 4db77055ed | |||
| 73c9bc1f14 | |||
| 7d4ffee203 | |||
| 6e8cb8d560 |
@@ -0,0 +1,84 @@
|
||||
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
|
||||
|
||||
|
||||
ssh -i ~/.ssh/key \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||
"mkdir -p ~/programs/etf-oglasi-server/config"
|
||||
|
||||
|
||||
ssh -i ~/.ssh/key \
|
||||
"${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||
"cd ~/programs/etf-oglasi-server && docker compose pull && docker compose up -d"
|
||||
+65
-58
@@ -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,33 @@ 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
|
||||
backend/src/main/resources/firebase.json
|
||||
|
||||
# Java / Eclipse / STS
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
@@ -34,54 +50,45 @@ 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
|
||||
frontend/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
|
||||
CLAUDE.md
|
||||
ksan.dev.keystore
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# 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
|
||||
|
||||
|
||||
need to made .creds .env and init directory with subjects.txt
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,42 @@
|
||||
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.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class FirebaseConfig {
|
||||
|
||||
@Value("${firebase.credentials.path}")
|
||||
private String credentialsPath;
|
||||
|
||||
@Bean
|
||||
public FirebaseApp firebaseApp() throws IOException {
|
||||
GoogleCredentials credentials = GoogleCredentials
|
||||
.fromStream(new FileInputStream(credentialsPath))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
|
||||
|
||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||
import dev.ksan.etfoglasiserver.service.UserService;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -33,9 +33,15 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
String token = null;
|
||||
String username = null;
|
||||
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
try {
|
||||
username = jwtService.extractEmail(token);
|
||||
} catch (JwtException e) {
|
||||
// expired, malformed, or otherwise invalid token - treat as unauthenticated
|
||||
username = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
@@ -13,8 +14,14 @@ 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;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -29,19 +36,39 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
|
||||
return http.csrf(customizer -> customizer.disable()).
|
||||
authorizeHttpRequests(request -> request
|
||||
return http
|
||||
.csrf(customizer -> customizer.disable())
|
||||
.cors(cors -> {})
|
||||
.authorizeHttpRequests(request -> request
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/login", "/api/register").permitAll()
|
||||
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
||||
.anyRequest().authenticated()).
|
||||
httpBasic(Customizer.withDefaults()).
|
||||
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
||||
config.setAllowedOrigins(List.of(
|
||||
"http://localhost:8081",
|
||||
"https://etf-oglasi.ksan.dev"
|
||||
));
|
||||
|
||||
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,4 +88,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")
|
||||
@@ -23,7 +25,7 @@ public class EntryController {
|
||||
return service.getEntryById(entryId);
|
||||
}
|
||||
|
||||
//ignore this for now
|
||||
// TODO only for testing
|
||||
@PostMapping("/entries")
|
||||
public void addEntry(@RequestBody EntryDTO entry) {
|
||||
service.addEntry(entry);
|
||||
@@ -36,23 +38,38 @@ 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);
|
||||
}
|
||||
|
||||
@DeleteMapping("/entries/{entryId}")
|
||||
public void deleteEntry(@PathVariable String entryId) {
|
||||
service.deleteEntry(entryId);
|
||||
}
|
||||
// @PutMapping("/entries")
|
||||
// public void updateEntry(@RequestBody EntryDTO entry) {
|
||||
// service.updateEntry(entry);
|
||||
// }
|
||||
|
||||
// @DeleteMapping("/entries/{entryId}")
|
||||
// public void deleteEntry(@PathVariable String entryId) {
|
||||
// service.deleteEntry(entryId);
|
||||
// }
|
||||
//
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import javax.crypto.KeyGenerator;
|
||||
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<>();
|
||||
|
||||
|
||||
return Jwts.builder().claims().add(claims).subject(email)
|
||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300))
|
||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
|
||||
.and().signWith(getKey()).compact();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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 {
|
||||
String id = firebaseMessaging.send(message);
|
||||
System.out.println("Send:" + id);
|
||||
} catch (FirebaseMessagingException ex) {
|
||||
|
||||
ex.printStackTrace();
|
||||
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,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=
|
||||
+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,72 @@
|
||||
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,
|
||||
setBackgroundMessageHandler,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications';
|
||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
// Registered at module scope so it's installed as soon as this entry file
|
||||
// loads, which is required for it to fire while the app is backgrounded/killed.
|
||||
setBackgroundMessageHandler(getMessaging(), async (remoteMessage) => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
await displayLocalNotification(title, body);
|
||||
});
|
||||
|
||||
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.data?.title as string | undefined) ?? 'New Notification',
|
||||
text2: msg.data?.body as string | undefined,
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// components/UpdatePrompt.tsx
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modal that appears when a newer build is available.
|
||||
// "Update now" opens the Gitea release page in the browser.
|
||||
// "Later" dismisses it for the current session (it reappears next launch).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { UpdateInfo } from '../hooks/useUpdatecheck';
|
||||
import { version as currentVersion } from '../version.json';
|
||||
|
||||
interface Props {
|
||||
updateInfo: UpdateInfo;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
|
||||
function openRelease() {
|
||||
Linking.openURL(updateInfo.releaseUrl).catch(() => {
|
||||
// If the device can't open the URL, just dismiss so the app isn't stuck.
|
||||
onDismiss();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onDismiss}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
|
||||
<Text style={styles.body}>
|
||||
A new version of the app is available.{'\n'}
|
||||
You are on build <Text style={styles.bold}>{currentVersion}</Text>,
|
||||
the latest is build{' '}
|
||||
<Text style={styles.bold}>{updateInfo.latestVersion}</Text>.
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnSecondary]}
|
||||
onPress={onDismiss}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Remind me later"
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>Later</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnPrimary]}
|
||||
onPress={openRelease}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open the release page to update"
|
||||
>
|
||||
<Text style={styles.btnPrimaryText}>Update now</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#111',
|
||||
marginBottom: 12,
|
||||
},
|
||||
body: {
|
||||
fontSize: 15,
|
||||
color: '#444',
|
||||
lineHeight: 22,
|
||||
marginBottom: 24,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: '700',
|
||||
color: '#111',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
btnPrimary: {
|
||||
backgroundColor: '#2563EB',
|
||||
},
|
||||
btnPrimaryText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
btnSecondary: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
btnSecondaryText: {
|
||||
color: '#374151',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const GITEA_URL = 'https://git.ksan.dev';
|
||||
|
||||
export const GITEA_OWNER = 'ksan';
|
||||
|
||||
export const GITEA_REPO = 'etf-oglasi';
|
||||
|
||||
|
||||
export const RELEASE_TAG_PREFIX = 'mobile-v';
|
||||
|
||||
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import Toast from "react-native-toast-message";
|
||||
import {
|
||||
authApi,
|
||||
|
||||
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
||||
LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
|
||||
} from "@/services/api";
|
||||
|
||||
type User = {
|
||||
@@ -11,6 +12,7 @@ type User = {
|
||||
email: string;
|
||||
token: string;
|
||||
subscribedSubjectIds: string[];
|
||||
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
@@ -23,6 +25,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 +46,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(u);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
persist(null);
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: 'Session expired',
|
||||
text2: 'Please sign in again.',
|
||||
position: 'top',
|
||||
visibilityTime: 4000,
|
||||
});
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
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 +86,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 +119,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 +129,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 +141,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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import messaging from "@react-native-firebase/messaging";
|
||||
import notifee, { AndroidImportance, EventType } from "@notifee/react-native";
|
||||
|
||||
|
||||
async function displayLocalNotification(title?: string, body?: string) {
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: "default",
|
||||
name: "General",
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: "default" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function usePushNotifications() {
|
||||
useEffect(() => {
|
||||
const unsubscribeMessage = messaging().onMessage(async remoteMessage => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
await displayLocalNotification(title, body);
|
||||
});
|
||||
|
||||
const unsubscribeNotifee = notifee.onForegroundEvent(({ type, detail }) => {
|
||||
if (type === EventType.PRESS) {
|
||||
console.log("Notification tapped:", detail.notification);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeMessage();
|
||||
unsubscribeNotifee();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
GITEA_URL,
|
||||
GITEA_OWNER,
|
||||
GITEA_REPO,
|
||||
RELEASE_TAG_PREFIX,
|
||||
UPDATE_CHECK_TIMEOUT_MS,
|
||||
} from '../config/gitea';
|
||||
import { version as currentVersion } from '../version.json';
|
||||
|
||||
export interface UpdateInfo {
|
||||
/** Build number of the latest release (same unit as currentVersion). */
|
||||
latestVersion: number;
|
||||
/** Direct URL the user can open to read the release notes / download the APK. */
|
||||
releaseUrl: string;
|
||||
/** Tag name as stored on Gitea, e.g. "mobile-v42". */
|
||||
tagName: string;
|
||||
}
|
||||
|
||||
export interface UseUpdateCheckResult {
|
||||
/** True while the API call is in flight. */
|
||||
checking: boolean;
|
||||
/** Populated once the check completes and a newer build exists. */
|
||||
updateInfo: UpdateInfo | null;
|
||||
/** Non-null when the check failed (network error, timeout, bad response). */
|
||||
error: Error | null;
|
||||
/** Re-run the check manually (e.g. from a "check again" button). */
|
||||
recheck: () => void;
|
||||
}
|
||||
|
||||
// ─── Gitea releases API ──────────────────────────────────────────────────────
|
||||
|
||||
interface GiteaRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
async function fetchLatestRelease(): Promise<GiteaRelease> {
|
||||
const url = `${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/latest`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Gitea API returned HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return (await res.json()) as GiteaRelease;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the integer build number out of a tag like "mobile-v42". Returns NaN if it can't. */
|
||||
function parseBuildNumber(tagName: string): number {
|
||||
if (!tagName.startsWith(RELEASE_TAG_PREFIX)) return NaN;
|
||||
return parseInt(tagName.slice(RELEASE_TAG_PREFIX.length), 10);
|
||||
}
|
||||
|
||||
|
||||
export function useUpdatecheck(): UseUpdateCheckResult {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
// A counter we bump to re-trigger the effect without changing the dep array shape.
|
||||
const [trigger, setTrigger] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function check() {
|
||||
setChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const release = await fetchLatestRelease();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Skip drafts and pre-releases.
|
||||
if (release.draft || release.prerelease) {
|
||||
setUpdateInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestVersion = parseBuildNumber(release.tag_name);
|
||||
|
||||
if (isNaN(latestVersion)) {
|
||||
// The tag doesn't follow our scheme — ignore it silently.
|
||||
setUpdateInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (latestVersion > currentVersion) {
|
||||
setUpdateInfo({
|
||||
latestVersion,
|
||||
releaseUrl: release.html_url,
|
||||
tagName: release.tag_name,
|
||||
});
|
||||
} else {
|
||||
setUpdateInfo(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// Don't surface AbortError as a real error — it's just a timeout.
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
setError(new Error('Update check timed out. Check your connection.'));
|
||||
} else {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trigger]);
|
||||
|
||||
return {
|
||||
checking,
|
||||
updateInfo,
|
||||
error,
|
||||
recheck: () => setTrigger(t => t + 1),
|
||||
};
|
||||
}
|
||||
+47
-5
@@ -2,24 +2,52 @@
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { enableScreens } from "react-native-screens";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import messaging from "@react-native-firebase/messaging";
|
||||
import notifee, { AndroidImportance } from "@notifee/react-native";
|
||||
|
||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||
import AllFeed from "@/screens/AllFeed";
|
||||
import Profile from "@/screens/Profile";
|
||||
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 { useUpdatecheck } from "./hooks/useUpdatecheck";
|
||||
import { UpdatePrompt } from "./components/UpdatePrompt";
|
||||
import { usePushNotifications } from "./hooks/usePushNotifications";
|
||||
|
||||
// Handles data-only FCM messages when the app is in the background or closed.
|
||||
messaging().setBackgroundMessageHandler(async remoteMessage => {
|
||||
const title = remoteMessage.data?.title as string | undefined;
|
||||
const body = remoteMessage.data?.body as string | undefined;
|
||||
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: "default",
|
||||
name: "General",
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: "default" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Must be called before any navigator renders
|
||||
enableScreens();
|
||||
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
const LIGHT_TAB = {
|
||||
@@ -29,6 +57,7 @@ const LIGHT_TAB = {
|
||||
inactive: "#8A8278",
|
||||
label: "#1A1714",
|
||||
};
|
||||
|
||||
const DARK_TAB = {
|
||||
bg: "#161513",
|
||||
border: "#2C2A27",
|
||||
@@ -37,13 +66,13 @@ const DARK_TAB = {
|
||||
label: "#F0EDE8",
|
||||
};
|
||||
|
||||
|
||||
function ProfileTab() {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return user ? <Profile /> : <AuthGate />;
|
||||
}
|
||||
|
||||
|
||||
export default function App() {
|
||||
const scheme = useColorScheme();
|
||||
const dark = scheme === "dark";
|
||||
@@ -53,6 +82,11 @@ export default function App() {
|
||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||
|
||||
const { updateInfo } = useUpdatecheck();
|
||||
const [updateDismissed, setUpdateDismissed] = useState(false);
|
||||
|
||||
usePushNotifications();
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
@@ -117,8 +151,16 @@ export default function App() {
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaProvider>
|
||||
|
||||
{/* Overlays the entire app including the tab bar */}
|
||||
{updateInfo && !updateDismissed && (
|
||||
<UpdatePrompt
|
||||
updateInfo={updateInfo}
|
||||
onDismiss={() => setUpdateDismissed(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
Generated
+1335
-432
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"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",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@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 +40,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>
|
||||
) : (
|
||||
|
||||
+42
-10
@@ -1,11 +1,19 @@
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api';
|
||||
|
||||
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
function authHeader(token?: string) {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
// Called once from AuthProvider so api.tsx can trigger a logout/relogin
|
||||
// when the backend rejects a request due to a missing/expired/invalid token.
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
@@ -25,7 +33,14 @@ 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);
|
||||
if (res.status === 401 && token) onUnauthorized?.();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -65,6 +80,7 @@ export type UserDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
subjectSet: Subject[];
|
||||
notificationType: NotificationType;
|
||||
|
||||
};
|
||||
|
||||
@@ -103,30 +119,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,55 @@
|
||||
import {
|
||||
getMessaging,
|
||||
requestPermission,
|
||||
getToken,
|
||||
onTokenRefresh,
|
||||
AuthorizationStatus,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||
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}/api/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);
|
||||
});
|
||||
}
|
||||
|
||||
export async function displayLocalNotification(title?: string, body?: string) {
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: 'default',
|
||||
name: 'General',
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: 'default' },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
Reference in New Issue
Block a user