Compare commits
30 Commits
d6657d83dd
...
mobile-v3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aa633a483 | |||
| 8404bcafd0 | |||
| 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
|
# GLOBAL (all projects)
|
||||||
backend/todo
|
########################
|
||||||
backend/.creds
|
|
||||||
backend/init/*
|
|
||||||
backend/*temp.java
|
|
||||||
backend/src/main/java/dev/ksan/etfoglasiserver/temp.java
|
|
||||||
|
|
||||||
|
todo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Java / Gradle
|
|
||||||
backend/.gradle/
|
|
||||||
backend/build/
|
|
||||||
|
|
||||||
# IDEs
|
# IDEs
|
||||||
.idea/
|
.idea/
|
||||||
@@ -21,7 +11,33 @@ backend/build/
|
|||||||
*.ipr
|
*.ipr
|
||||||
*.iws
|
*.iws
|
||||||
|
|
||||||
# Eclipse / STS
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
########################
|
||||||
|
# BACKEND (Spring Boot)
|
||||||
|
########################
|
||||||
|
|
||||||
|
backend/.gradle/
|
||||||
|
backend/build/
|
||||||
|
backend/bin/
|
||||||
|
backend/src/main/resources/application.properties
|
||||||
|
|
||||||
|
backend/.env
|
||||||
|
backend/.creds
|
||||||
|
backend/.db/
|
||||||
|
|
||||||
|
backend/init/*
|
||||||
|
backend/**/*temp.java
|
||||||
|
|
||||||
|
backend/src/main/resources/google-services.json
|
||||||
|
backend/src/main/resources/etf-oglasi-firebase.json
|
||||||
|
backend/src/main/resources/firebase.json
|
||||||
|
|
||||||
|
# Java / Eclipse / STS
|
||||||
.classpath
|
.classpath
|
||||||
.project
|
.project
|
||||||
.settings/
|
.settings/
|
||||||
@@ -34,54 +50,45 @@ nbbuild/
|
|||||||
nbdist/
|
nbdist/
|
||||||
.nb-gradle/
|
.nb-gradle/
|
||||||
|
|
||||||
# Environment & secrets
|
########################
|
||||||
backend/.env
|
# FRONTEND (React Native / Expo)
|
||||||
backend/.creds
|
########################
|
||||||
|
|
||||||
# Local database
|
frontend/node_modules/
|
||||||
backend/.db/
|
|
||||||
|
|
||||||
## frontend
|
frontend/google-services.json
|
||||||
|
|
||||||
# dependencies
|
frontend/.expo/
|
||||||
node_modules/
|
frontend/dist/
|
||||||
|
frontend/web-build/
|
||||||
|
|
||||||
|
frontend/.kotlin/
|
||||||
|
frontend/*.orig.*
|
||||||
|
frontend/*.jks
|
||||||
|
frontend/*.p8
|
||||||
|
frontend/*.p12
|
||||||
|
frontend/*.key
|
||||||
|
frontend/*.mobileprovision
|
||||||
|
|
||||||
|
frontend/.metro-health-check*
|
||||||
|
|
||||||
|
frontend/npm-debug.*
|
||||||
|
frontend/yarn-debug.*
|
||||||
|
frontend/yarn-error.*
|
||||||
|
|
||||||
|
frontend/.env
|
||||||
|
frontend/.env*.local
|
||||||
|
|
||||||
|
frontend/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Native builds
|
||||||
|
frontend/ios/
|
||||||
|
frontend/android/
|
||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
.expo/
|
frontend/expo-env.d.ts
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
.kotlin/
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
CLAUDE.md
|
||||||
npm-debug.*
|
ksan.dev.keystore
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
frontend/.env
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
app-example
|
|
||||||
|
|
||||||
# generated native folders
|
|
||||||
/ios
|
|
||||||
/android
|
|
||||||
|
|||||||
@@ -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")
|
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
|
||||||
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
|
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
|
||||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
|
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
|
||||||
|
implementation("org.projectlombok:lombok:1.18.42")
|
||||||
|
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||||
|
implementation 'com.google.firebase:firebase-admin:9.9.0'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
|
testRuntimeOnly 'com.h2database:h2'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation("org.testcontainers:postgresql:1.21.3")
|
||||||
|
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
|
||||||
|
testImplementation("org.mockito:mockito-inline:5.2.0")
|
||||||
|
testImplementation 'org.mockito:mockito-core'
|
||||||
}
|
}
|
||||||
|
|
||||||
generateJava {
|
generateJava {
|
||||||
@@ -49,4 +62,8 @@ generateJava {
|
|||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
|
||||||
|
jvmArgs += [
|
||||||
|
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('byte-buddy-agent') }}"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ services:
|
|||||||
image: 'postgres:16'
|
image: 'postgres:16'
|
||||||
container_name: etfo-db
|
container_name: etfo-db
|
||||||
environment:
|
environment:
|
||||||
- 'POSTGRES_DB=etfo-db'
|
- POSTGRES_DB=etfo-db
|
||||||
- 'POSTGRES_PASSWORD=test'
|
- POSTGRES_PASSWORD=test
|
||||||
- 'POSTGRES_USER=test'
|
- POSTGRES_USER=test
|
||||||
volumes:
|
volumes:
|
||||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
- ./.db:/var/lib/postgresql/data
|
- ./.db:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- '5432:5432'
|
- 5432:5432
|
||||||
|
|||||||
+27
-1
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
|
|||||||
CONSTRAINT subjects_pkey PRIMARY KEY (id)
|
CONSTRAINT subjects_pkey PRIMARY KEY (id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public."user-subject"
|
CREATE TABLE IF NOT EXISTS public."user_subject"
|
||||||
(
|
(
|
||||||
user_id uuid NOT NULL,
|
user_id uuid NOT NULL,
|
||||||
subject_id uuid NOT NULL,
|
subject_id uuid NOT NULL,
|
||||||
@@ -55,6 +55,32 @@ CREATE TABLE IF NOT EXISTS public.users
|
|||||||
CONSTRAINT unique_email UNIQUE (email)
|
CONSTRAINT unique_email UNIQUE (email)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.device_tokens
|
||||||
|
(
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
fcm_token text NOT NULL,
|
||||||
|
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT device_tokens_pkey PRIMARY KEY (id),
|
||||||
|
|
||||||
|
CONSTRAINT fk_device_tokens_user
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES public.users(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT uq_device_tokens_fcm_token
|
||||||
|
UNIQUE (fcm_token)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_device_tokens_user_id
|
||||||
|
ON public.device_tokens(user_id);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ALTER TABLE IF EXISTS public.alt_subject
|
ALTER TABLE IF EXISTS public.alt_subject
|
||||||
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
|
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
|
||||||
REFERENCES public.subjects (id) MATCH SIMPLE
|
REFERENCES public.subjects (id) MATCH SIMPLE
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class EtfoglasiServerApplication {
|
public class EtfoglasiServerApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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.JWTService;
|
||||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||||
import dev.ksan.etfoglasiserver.service.UserService;
|
import io.jsonwebtoken.JwtException;
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -33,9 +33,15 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
String token = null;
|
String token = null;
|
||||||
String username = null;
|
String username = null;
|
||||||
|
|
||||||
|
|
||||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||||
token = authHeader.substring(7);
|
token = authHeader.substring(7);
|
||||||
|
try {
|
||||||
username = jwtService.extractEmail(token);
|
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) {
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.authentication.AuthenticationProvider;
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
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.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -29,19 +36,39 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
|
||||||
return http.csrf(customizer -> customizer.disable()).
|
return http
|
||||||
authorizeHttpRequests(request -> request
|
.csrf(customizer -> customizer.disable())
|
||||||
|
.cors(cors -> {})
|
||||||
|
.authorizeHttpRequests(request -> request
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/login", "/api/register").permitAll()
|
.requestMatchers("/api/login", "/api/register").permitAll()
|
||||||
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
||||||
.anyRequest().authenticated()).
|
.anyRequest().authenticated()
|
||||||
httpBasic(Customizer.withDefaults()).
|
)
|
||||||
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.httpBasic(Customizer.withDefaults())
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.build();
|
.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.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
@@ -23,7 +25,7 @@ public class EntryController {
|
|||||||
return service.getEntryById(entryId);
|
return service.getEntryById(entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
//ignore this for now
|
// TODO only for testing
|
||||||
@PostMapping("/entries")
|
@PostMapping("/entries")
|
||||||
public void addEntry(@RequestBody EntryDTO entry) {
|
public void addEntry(@RequestBody EntryDTO entry) {
|
||||||
service.addEntry(entry);
|
service.addEntry(entry);
|
||||||
@@ -36,23 +38,38 @@ public class EntryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("/entries")
|
@GetMapping("/entries")
|
||||||
public Page<Entry> getEntries(
|
public Page<Entry> getEntries(
|
||||||
@RequestParam(required = false) String subjectId,
|
@RequestParam(required = false) String subjectId,
|
||||||
@RequestParam(required = false) String groupName,
|
@RequestParam(required = false) String groupName,
|
||||||
@RequestParam(defaultValue = "0") int page
|
@RequestParam(defaultValue = "0") int page
|
||||||
) {
|
) {
|
||||||
UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
// UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
||||||
|
|
||||||
|
UUID parsedSubjectId = null;
|
||||||
|
if (subjectId != null) {
|
||||||
|
try{
|
||||||
|
parsedSubjectId = UUID.fromString(subjectId);
|
||||||
|
}catch (IllegalArgumentException e){
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid subject ID");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return service.getEntries(parsedSubjectId, groupName, page);
|
return service.getEntries(parsedSubjectId, groupName, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/entries")
|
|
||||||
public void updateEntry(@RequestBody EntryDTO entry) {
|
|
||||||
service.updateEntry(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/entries/{entryId}")
|
// @PutMapping("/entries")
|
||||||
public void deleteEntry(@PathVariable String entryId) {
|
// public void updateEntry(@RequestBody EntryDTO entry) {
|
||||||
service.deleteEntry(entryId);
|
// 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);
|
return service.getSubjectAlt(subjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/subjectalt")
|
// @PostMapping("/subjectalt")
|
||||||
public void addSubject(@RequestBody SubjectAlt subject) {
|
// public void addSubject(@RequestBody SubjectAlt subject) {
|
||||||
service.addSubjectAlt(subject);
|
// service.addSubjectAlt(subject);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
@DeleteMapping("/subjectalt/{subjectId}")
|
// @DeleteMapping("/subjectalt/{subjectId}")
|
||||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||||
service.deleteSubjectAlt(subjectId);
|
// service.deleteSubjectAlt(subjectId);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,21 @@ public class SubjectController {
|
|||||||
return service.getSubject(subjectId);
|
return service.getSubject(subjectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/subjects")
|
@PostMapping("/subjects")
|
||||||
public void addSubject(@RequestBody Subject subject) {
|
public void addSubject(@RequestBody Subject subject) {
|
||||||
service.addSubject(subject);
|
service.addSubject(subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/subjects")
|
//TODO uncomment this if ever roles are added so only admin should modify this (didnt really feel like doing it rn)
|
||||||
public void updateSubject(@RequestBody Subject subject) {
|
//
|
||||||
service.updateSubject(subject);
|
// @PutMapping("/subjects")
|
||||||
}
|
// public void updateSubject(@RequestBody Subject subject) {
|
||||||
|
// service.updateSubject(subject);
|
||||||
|
// }
|
||||||
|
|
||||||
@DeleteMapping("/subjects/{subjectId}")
|
// @DeleteMapping("/subjects/{subjectId}")
|
||||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||||
service.deleteSubject(subjectId);
|
// service.deleteSubject(subjectId);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
|||||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||||
|
import dev.ksan.etfoglasiserver.model.User;
|
||||||
import dev.ksan.etfoglasiserver.service.UserService;
|
import dev.ksan.etfoglasiserver.service.UserService;
|
||||||
import java.util.List;
|
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api")
|
@RequestMapping("/api")
|
||||||
@@ -20,76 +24,106 @@ public class UserController {
|
|||||||
@Autowired UserService service;
|
@Autowired UserService service;
|
||||||
|
|
||||||
|
|
||||||
@GetMapping("users/{userId}")
|
@GetMapping("/users/me")
|
||||||
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID userId) {
|
public ResponseEntity<UserDTO> getUserById(Authentication authentication) {
|
||||||
|
String email = authentication.getName();
|
||||||
|
|
||||||
UserDTO user = service.getUserById(userId);
|
User user = service.getAccountByEmail(email)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "User not found"
|
||||||
|
));
|
||||||
|
|
||||||
if (user != null) {
|
return ResponseEntity.ok(new UserDTO(user));
|
||||||
return new ResponseEntity<>(user, HttpStatus.OK);
|
|
||||||
}
|
|
||||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users")
|
@PutMapping("/users/me")
|
||||||
public ResponseEntity<UserDTO> updateUser(@RequestBody UserCreationDTO user) {
|
public ResponseEntity<UserDTO> updateUser(
|
||||||
|
@RequestBody UserCreationDTO user,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
UserDTO userUpdated = service.updateUser(user);
|
String email = authentication.getName();
|
||||||
if (userUpdated != null) {
|
|
||||||
return new ResponseEntity<>(userUpdated, HttpStatus.OK);
|
UserDTO userUpdated = service.updateUser(email, user);
|
||||||
}
|
|
||||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
return ResponseEntity.ok(userUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{userId}/subjects/{subjectId}")
|
|
||||||
|
|
||||||
|
@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(
|
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);
|
return ResponseEntity.ok(updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{userId}/subjects/{subjectId}")
|
@DeleteMapping("/users/me/subjects/{subjectId}")
|
||||||
public ResponseEntity<UserDTO> removeSubject(
|
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);
|
return ResponseEntity.ok(updatedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// not the best, should fix this later
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
|
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
|
||||||
|
try {
|
||||||
JwtResponseDTO userVer = service.verify(user);
|
JwtResponseDTO userVer = service.verify(user);
|
||||||
|
|
||||||
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
|
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
|
||||||
|
}catch (Exception e) {
|
||||||
|
|
||||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// not the best, should fix this later too
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
|
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
|
||||||
|
|
||||||
|
try {
|
||||||
UserDTO newUser = service.register(user);
|
UserDTO newUser = service.register(user);
|
||||||
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED);
|
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED);
|
||||||
|
}catch (Exception e) {
|
||||||
|
|
||||||
|
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
//
|
|
||||||
// @DeleteMapping("/users/{userId}")
|
|
||||||
// public void deleteUser(@PathVariable UUID userId) {
|
|
||||||
// service.deleteUser(userId);
|
|
||||||
// }
|
|
||||||
|
|
||||||
/*
|
|
||||||
@PostMapping("/users")
|
|
||||||
public void addUser(@RequestBody UserCreationDTO user) {
|
@DeleteMapping("/users/me")
|
||||||
NotificationMethod method = NotificationMethod.valueOf(user.getNotification());
|
public ResponseEntity<Void> deleteUser(Authentication authentication) {
|
||||||
user.setNotificationMethod(method);
|
|
||||||
service.addUser(user);
|
String email = authentication.getName();
|
||||||
|
|
||||||
|
service.deleteUser(email);
|
||||||
|
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ public class UserCreationDTO {
|
|||||||
private Set<Subject> subjectSet;
|
private Set<Subject> subjectSet;
|
||||||
private String notification;
|
private String notification;
|
||||||
|
|
||||||
|
public UserCreationDTO(String mail, String password) {
|
||||||
|
this.email = mail;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
public String getEmail() {
|
public String getEmail() {
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,26 @@ package dev.ksan.etfoglasiserver.dto;
|
|||||||
|
|
||||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||||
import dev.ksan.etfoglasiserver.model.Subject;
|
import dev.ksan.etfoglasiserver.model.Subject;
|
||||||
|
import dev.ksan.etfoglasiserver.model.User;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class UserDTO {
|
public class UserDTO {
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
private String email;
|
private String email;
|
||||||
|
@Setter
|
||||||
|
@Getter
|
||||||
private Set<Subject> subjectSet;
|
private Set<Subject> subjectSet;
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
private NotificationMethod notificationType;
|
||||||
|
|
||||||
public UserDTO() {}
|
public UserDTO() {}
|
||||||
|
|
||||||
@@ -17,31 +30,17 @@ public class UserDTO {
|
|||||||
this.id = id;
|
this.id = id;
|
||||||
this.email = email;
|
this.email = email;
|
||||||
this.subjectSet = subjectSet;
|
this.subjectSet = subjectSet;
|
||||||
|
this.notificationType = notificationMethod2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
public UserDTO(User user) {
|
||||||
return id;
|
this.id = user.getId();
|
||||||
}
|
this.email = user.getEmail();
|
||||||
|
this.subjectSet = user.getSubjectSet();
|
||||||
|
this.notificationType = user.getNotificationMethod();
|
||||||
|
|
||||||
public void setId(UUID id) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getEmail() {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEmail(String email) {
|
|
||||||
this.email = email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<Subject> getSubjectSet() {
|
|
||||||
return subjectSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSubjectSet(Set<Subject> subjectSet) {
|
|
||||||
this.subjectSet = subjectSet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package dev.ksan.etfoglasiserver.dto;
|
package dev.ksan.etfoglasiserver.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
public class UserLoginDTO {
|
public class UserLoginDTO {
|
||||||
|
|
||||||
private String email;
|
private String email;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.ksan.etfoglasiserver.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Getter // ← add class-level getter
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "device_tokens",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(columnNames = "fcm_token")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class DeviceToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Setter
|
||||||
|
@Column(name = "fcm_token", nullable = false)
|
||||||
|
private String fcmToken;
|
||||||
|
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
@PreUpdate
|
||||||
|
public void touch() {
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,5 @@ package dev.ksan.etfoglasiserver.model;
|
|||||||
public enum NotificationMethod {
|
public enum NotificationMethod {
|
||||||
|
|
||||||
NO_NOTIFICATION,
|
NO_NOTIFICATION,
|
||||||
EMAIL,
|
|
||||||
PUSH_NOTIFICATION
|
PUSH_NOTIFICATION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.ksan.etfoglasiserver.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class RegisterTokenRequest {
|
||||||
|
private String token;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.ksan.etfoglasiserver.model;
|
package dev.ksan.etfoglasiserver.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -9,13 +11,18 @@ import java.util.UUID;
|
|||||||
@Table(name = "subjects")
|
@Table(name = "subjects")
|
||||||
public class Subject {
|
public class Subject {
|
||||||
|
|
||||||
|
@Getter
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
@Column(columnDefinition = "uuid")
|
@Column(columnDefinition = "uuid")
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
@Column private long code;
|
@Column private long code;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@@ -26,23 +33,4 @@ public class Subject {
|
|||||||
public Subject(){
|
public Subject(){
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getCode() {
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCode(long code) {
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setName(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -18,9 +20,11 @@ public class SubjectAlt {
|
|||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
|
||||||
|
@Setter
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
String name;
|
String name;
|
||||||
|
|
||||||
|
@Setter
|
||||||
@ManyToOne(fetch = FetchType.EAGER)
|
@ManyToOne(fetch = FetchType.EAGER)
|
||||||
@JoinColumn(nullable = false)
|
@JoinColumn(nullable = false)
|
||||||
private Subject subject;
|
private Subject subject;
|
||||||
@@ -32,22 +36,15 @@ public class SubjectAlt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
public UUID getId() {
|
||||||
return id;
|
return this.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return this.name;
|
||||||
}
|
|
||||||
|
|
||||||
public void setName(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Subject getSubject() {
|
public Subject getSubject() {
|
||||||
return subject;
|
return this.subject;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSubject(Subject subject) {
|
|
||||||
this.subject = subject;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package dev.ksan.etfoglasiserver.model;
|
|||||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -24,18 +26,40 @@ public class User {
|
|||||||
nullable = false,
|
nullable = false,
|
||||||
updatable = false,
|
updatable = false,
|
||||||
insertable = false,
|
insertable = false,
|
||||||
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
)
|
||||||
private LocalDateTime regTime;
|
private LocalDateTime regTime;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "notification_type", nullable = false)
|
@Column(name = "notification_type", nullable = false)
|
||||||
private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION;
|
private NotificationMethod notificationType =
|
||||||
|
NotificationMethod.PUSH_NOTIFICATION;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.LAZY)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"),
|
@JoinTable(
|
||||||
inverseJoinColumns = @JoinColumn(name = "subject_id"))
|
name = "user_subject",
|
||||||
|
joinColumns = @JoinColumn(name = "user_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "subject_id")
|
||||||
|
)
|
||||||
private Set<Subject> subjectSet;
|
private Set<Subject> subjectSet;
|
||||||
|
|
||||||
|
@OneToMany(
|
||||||
|
mappedBy = "user",
|
||||||
|
cascade = CascadeType.ALL,
|
||||||
|
orphanRemoval = true,
|
||||||
|
fetch = FetchType.LAZY
|
||||||
|
)
|
||||||
|
private List<DeviceToken> deviceTokens = new ArrayList<>();
|
||||||
|
|
||||||
|
public void addDeviceToken(DeviceToken token) {
|
||||||
|
deviceTokens.add(token);
|
||||||
|
token.setUser(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeDeviceToken(DeviceToken token) {
|
||||||
|
deviceTokens.remove(token);
|
||||||
|
token.setUser(null);
|
||||||
|
}
|
||||||
public User() {}
|
public User() {}
|
||||||
|
|
||||||
public User(UserCreationDTO user) {
|
public User(UserCreationDTO user) {
|
||||||
@@ -89,4 +113,5 @@ public class User {
|
|||||||
public NotificationMethod getNotificationMethod() {
|
public NotificationMethod getNotificationMethod() {
|
||||||
return notificationType;
|
return notificationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.ksan.etfoglasiserver.repository;
|
||||||
|
|
||||||
|
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||||
|
import dev.ksan.etfoglasiserver.model.Entry;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface DeviceTokenRepo extends JpaRepository<DeviceToken, UUID> {
|
||||||
|
|
||||||
|
List<DeviceToken> findByUserId(UUID userId);
|
||||||
|
|
||||||
|
Optional<DeviceToken> findByFcmToken(String fcmToken);
|
||||||
|
|
||||||
|
void deleteByFcmToken(String fcmToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.ksan.etfoglasiserver.service;
|
||||||
|
|
||||||
|
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||||
|
import dev.ksan.etfoglasiserver.model.User;
|
||||||
|
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
|
||||||
|
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeviceTokenService {
|
||||||
|
|
||||||
|
private final DeviceTokenRepo deviceTokenRepo;
|
||||||
|
private final UserRepo userRepo;
|
||||||
|
|
||||||
|
public void registerToken(String email, String token) {
|
||||||
|
|
||||||
|
User user = userRepo.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
|
DeviceToken deviceToken = deviceTokenRepo
|
||||||
|
.findByFcmToken(token)
|
||||||
|
.orElse(new DeviceToken());
|
||||||
|
|
||||||
|
deviceToken.setUser(user);
|
||||||
|
deviceToken.setFcmToken(token);
|
||||||
|
|
||||||
|
deviceTokenRepo.save(deviceToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ public class EntryService {
|
|||||||
Entry newEntry = new Entry(entry);
|
Entry newEntry = new Entry(entry);
|
||||||
newEntry.setSubject(subject);
|
newEntry.setSubject(subject);
|
||||||
entryRepo.save(newEntry);
|
entryRepo.save(newEntry);
|
||||||
|
|
||||||
|
subjectService.notifyAsync(newEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addEntry(Entry entry) {
|
public void addEntry(Entry entry) {
|
||||||
@@ -63,7 +65,7 @@ public class EntryService {
|
|||||||
System.out.println("Duplicate");
|
System.out.println("Duplicate");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
subjectService.notify(entry);
|
subjectService.notifyAsync(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateEntry(EntryDTO entry) {
|
public void updateEntry(EntryDTO entry) {
|
||||||
|
|||||||
@@ -1,42 +1,31 @@
|
|||||||
package dev.ksan.etfoglasiserver.service;
|
package dev.ksan.etfoglasiserver.service;
|
||||||
|
|
||||||
import dev.ksan.etfoglasiserver.model.User;
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import javax.crypto.KeyGenerator;
|
|
||||||
import javax.crypto.SecretKey;
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
import io.jsonwebtoken.io.Decoders;
|
import io.jsonwebtoken.io.Decoders;
|
||||||
import io.jsonwebtoken.security.Keys;
|
import io.jsonwebtoken.security.Keys;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class JWTService {
|
public class JWTService {
|
||||||
|
|
||||||
//TODO add persistent token env variable
|
@Value("${jwt.secret}")
|
||||||
private String secretKey = "";
|
private String secretKey;
|
||||||
|
|
||||||
|
|
||||||
private JWTService() {
|
|
||||||
try {
|
|
||||||
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
|
|
||||||
SecretKey secKey = keyGen.generateKey();
|
|
||||||
secretKey = Base64.getEncoder().encodeToString(secKey.getEncoded());
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public String generateToken(String email) {
|
public String generateToken(String email) {
|
||||||
Map<String, Object> claims = new HashMap<>();
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
|
||||||
|
|
||||||
return Jwts.builder().claims().add(claims).subject(email)
|
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();
|
.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;
|
package dev.ksan.etfoglasiserver.service;
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingException;
|
||||||
import dev.ksan.etfoglasiserver.model.Entry;
|
import dev.ksan.etfoglasiserver.model.Entry;
|
||||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||||
import dev.ksan.etfoglasiserver.model.Subject;
|
import dev.ksan.etfoglasiserver.model.Subject;
|
||||||
@@ -8,22 +9,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
|
||||||
import org.simplejavamail.api.mailer.Mailer;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SubjectService {
|
public class SubjectService {
|
||||||
|
|
||||||
@Autowired private SubjectRepo subjectRepo;
|
@Autowired private SubjectRepo subjectRepo;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserRepo userRepo;
|
private NotificationService notificationService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private MailService mailService;
|
|
||||||
|
|
||||||
public List<Subject> getSubjects() {
|
public List<Subject> getSubjects() {
|
||||||
return subjectRepo.findAll();
|
return subjectRepo.findAll();
|
||||||
@@ -67,29 +68,31 @@ public class SubjectService {
|
|||||||
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
|
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void notify(Entry entry) {
|
|
||||||
List<User> users = userService.findUsersBySubjectId(entry.getSubject().getId());
|
//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;
|
if (users == null || users.isEmpty()) return;
|
||||||
|
|
||||||
for (User user : users) {
|
for (User user : users) {
|
||||||
if(user.getNotificationMethod() == NotificationMethod.EMAIL)
|
|
||||||
this.sendEmailNotification(entry, user);
|
if (user.getNotificationMethod() != NotificationMethod.PUSH_NOTIFICATION)
|
||||||
if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION)
|
continue;
|
||||||
this.sendPushNotification(entry, user);
|
|
||||||
System.out.println(user.getEmail());
|
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() {
|
public long count() {
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
|
|||||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||||
|
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||||
import dev.ksan.etfoglasiserver.model.Subject;
|
import dev.ksan.etfoglasiserver.model.Subject;
|
||||||
import dev.ksan.etfoglasiserver.model.User;
|
import dev.ksan.etfoglasiserver.model.User;
|
||||||
import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
||||||
@@ -23,6 +24,7 @@ import org.springframework.security.core.Authentication;
|
|||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -61,8 +63,8 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserDTO updateUser(UserCreationDTO user) {
|
public UserDTO updateUser(String email, UserCreationDTO user) {
|
||||||
Optional<User> existingUserOpt = userRepo.findByEmail(user.getEmail());
|
Optional<User> existingUserOpt = userRepo.findByEmail(email);
|
||||||
|
|
||||||
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
|
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
|
||||||
throw new RuntimeException("Email taken");
|
throw new RuntimeException("Email taken");
|
||||||
@@ -96,8 +98,12 @@ public class UserService {
|
|||||||
} else throw new RuntimeException("User not found");
|
} else throw new RuntimeException("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteUser(UUID userId) {
|
public void deleteUser(String email) {
|
||||||
userRepo.deleteById(userId);
|
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
|
user.getSubjectSet().clear();
|
||||||
|
userRepo.save(user);
|
||||||
|
userRepo.delete(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserDTO register(UserCreationDTO user) {
|
public UserDTO register(UserCreationDTO user) {
|
||||||
@@ -144,19 +150,18 @@ public class UserService {
|
|||||||
|
|
||||||
public JwtResponseDTO verify(UserLoginDTO user) {
|
public JwtResponseDTO verify(UserLoginDTO user) {
|
||||||
|
|
||||||
Authentication authentication = authManager.authenticate(
|
authManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword())
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
user.getEmail(),
|
||||||
|
user.getPassword()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!authentication.isAuthenticated()) {
|
User foundUser = userRepo.findByEmail(user.getEmail())
|
||||||
throw new BadCredentialsException("Invalid username or password");
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
}
|
|
||||||
|
|
||||||
String token = jwtService.generateToken(user.getEmail());
|
String token = jwtService.generateToken(user.getEmail());
|
||||||
|
|
||||||
User foundUser = userRepo.findByEmail(user.getEmail())
|
|
||||||
.orElseThrow(() -> new RuntimeException("User not found after authentication"));
|
|
||||||
|
|
||||||
return new JwtResponseDTO(
|
return new JwtResponseDTO(
|
||||||
token,
|
token,
|
||||||
foundUser.getEmail(),
|
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"));
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
Subject subject = subjectRepo.findById(subjectId)
|
Subject subject = subjectRepo.findById(subjectId)
|
||||||
@@ -185,9 +190,9 @@ public class UserService {
|
|||||||
return getUserDTO(user);
|
return getUserDTO(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public UserDTO removeSubject(UUID userId, UUID subjectId) {
|
public UserDTO removeSubject(String email, UUID subjectId) {
|
||||||
|
|
||||||
User user = userRepo.findById(userId)
|
User user = userRepo.findByEmail(email)
|
||||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
Subject subject = subjectRepo.findById(subjectId)
|
Subject subject = subjectRepo.findById(subjectId)
|
||||||
@@ -200,4 +205,11 @@ public class UserService {
|
|||||||
|
|
||||||
return getUserDTO(user);
|
return getUserDTO(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updateNotificationType(String email, NotificationMethod notificationType) {
|
||||||
|
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||||
|
|
||||||
|
user.setNotificationMethod(notificationType);
|
||||||
|
userRepo.save(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
spring.application.name=etfoglasi-server
|
|
||||||
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
|
|
||||||
spring.datasource.username=test
|
|
||||||
spring.datasource.password=test
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
spring.application.name=
|
||||||
|
|
||||||
|
spring.datasource.url=
|
||||||
|
spring.datasource.username=
|
||||||
|
spring.datasource.password=
|
||||||
|
|
||||||
|
# maybe use > openssl rand -base64 32
|
||||||
|
jwt.secret=
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package dev.ksan.etfoglasiserver;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
class EtfoglasiServerApplicationTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void contextLoads() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+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,157 @@
|
|||||||
|
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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//we removed this from controller so test is pointless
|
||||||
|
// @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": {
|
"expo": {
|
||||||
"name": "etfoglasi-frontend",
|
"name": "etfoglasi",
|
||||||
"slug": "etfoglasi-frontend",
|
"slug": "etfoglasi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "etfoglasifrontend",
|
"scheme": "etfoglasi",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"googleServicesFile": "./google-services.json",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "dev.ksan.etfoglasi"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
@@ -39,7 +41,9 @@
|
|||||||
"backgroundColor": "#000000"
|
"backgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"@react-native-firebase/app",
|
||||||
|
"@react-native-firebase/messaging"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
@@ -47,4 +51,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,84 @@
|
|||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import { Stack } from "expo-router";
|
import { useEffect, useState } from 'react';
|
||||||
import {AuthProvider} from "@/context/AuthContext";
|
import { Stack } from 'expo-router';
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import {
|
||||||
|
getMessaging,
|
||||||
|
onMessage,
|
||||||
|
onNotificationOpenedApp,
|
||||||
|
getInitialNotification,
|
||||||
|
setBackgroundMessageHandler,
|
||||||
|
} from '@react-native-firebase/messaging';
|
||||||
|
import { registerDeviceToken, listenForTokenRefresh, displayLocalNotification } from '@/services/notifications';
|
||||||
|
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||||
|
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
|
||||||
|
import { UpdatePrompt } from '@/components/UpdatePrompt';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpdateCheck() {
|
||||||
|
const { updateInfo } = useUpdatecheck();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
if (!updateInfo || dismissed) return null;
|
||||||
|
|
||||||
|
return <UpdatePrompt updateInfo={updateInfo} onDismiss={() => setDismissed(true)} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
|
<NotificationSetup />
|
||||||
<Stack screenOptions={{ headerShown: false }} />
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
<UpdateCheck />
|
||||||
|
<Toast />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</AuthProvider>
|
</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 React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import Toast from "react-native-toast-message";
|
||||||
import {
|
import {
|
||||||
authApi,
|
authApi,
|
||||||
|
|
||||||
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
|
||||||
} from "@/services/api";
|
} from "@/services/api";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
@@ -11,6 +12,7 @@ type User = {
|
|||||||
email: string;
|
email: string;
|
||||||
token: string;
|
token: string;
|
||||||
subscribedSubjectIds: string[];
|
subscribedSubjectIds: string[];
|
||||||
|
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
@@ -23,6 +25,7 @@ type AuthContextValue = {
|
|||||||
unsubscribe: (subjectId: string) => Promise<void>;
|
unsubscribe: (subjectId: string) => Promise<void>;
|
||||||
isSubscribed: (subjectId: string) => boolean;
|
isSubscribed: (subjectId: string) => boolean;
|
||||||
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
||||||
|
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -43,17 +46,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(u);
|
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 login = async (payload: LoginPayload) => {
|
||||||
const res = await authApi.login(payload);
|
const res = await authApi.login(payload);
|
||||||
|
|
||||||
// Gracefully fetch subscriptions — don't block login if this fails
|
|
||||||
let subscribedSubjectIds: string[] = [];
|
let subscribedSubjectIds: string[] = [];
|
||||||
|
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
|
||||||
|
|
||||||
if (res.userId) {
|
if (res.userId) {
|
||||||
try {
|
try {
|
||||||
const dto = await authApi.getUser(res.userId, res.token);
|
const dto = await authApi.getUser(res.token);
|
||||||
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
||||||
} catch {
|
notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
|
||||||
// aaaa
|
} catch (e) {
|
||||||
|
console.error('Failed to load subscriptions on login:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +86,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
email: res.email,
|
email: res.email,
|
||||||
token: res.token,
|
token: res.token,
|
||||||
subscribedSubjectIds,
|
subscribedSubjectIds,
|
||||||
|
notificationType,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (payload: RegisterPayload) => {
|
const register = async (payload: RegisterPayload) => {
|
||||||
await authApi.register(payload);
|
await authApi.register(payload);
|
||||||
await login({ email: payload.email, password: payload.password });
|
await login({
|
||||||
|
email: payload.email.trim(),
|
||||||
|
password: payload.password.trim(),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
||||||
@@ -91,7 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const subscribe = async (subjectId: string) => {
|
const subscribe = async (subjectId: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token);
|
const dto = await subscriptionsApi.subscribe( subjectId, user.token);
|
||||||
const updated = {
|
const updated = {
|
||||||
...user,
|
...user,
|
||||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
||||||
@@ -101,7 +129,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const unsubscribe = async (subjectId: string) => {
|
const unsubscribe = async (subjectId: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token);
|
const dto = await subscriptionsApi.unsubscribe( subjectId, user.token);
|
||||||
const updated = {
|
const updated = {
|
||||||
...user,
|
...user,
|
||||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
||||||
@@ -113,7 +141,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}>
|
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser, updateNotificationType }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 "./globals.css";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { useColorScheme } from "react-native";
|
import { useColorScheme } from "react-native";
|
||||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 SubscribedFeed from "@/screens/SubscribedFeed";
|
||||||
import AllFeed from "@/screens/AllFeed";
|
import AllFeed from "@/screens/AllFeed";
|
||||||
import Profile from "@/screens/Profile";
|
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 AuthGate from "@/screens/AuthGate";
|
||||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
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
|
// Must be called before any navigator renders
|
||||||
enableScreens();
|
enableScreens();
|
||||||
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
|
|
||||||
const LIGHT_TAB = {
|
const LIGHT_TAB = {
|
||||||
@@ -29,6 +57,7 @@ const LIGHT_TAB = {
|
|||||||
inactive: "#8A8278",
|
inactive: "#8A8278",
|
||||||
label: "#1A1714",
|
label: "#1A1714",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DARK_TAB = {
|
const DARK_TAB = {
|
||||||
bg: "#161513",
|
bg: "#161513",
|
||||||
border: "#2C2A27",
|
border: "#2C2A27",
|
||||||
@@ -37,13 +66,13 @@ const DARK_TAB = {
|
|||||||
label: "#F0EDE8",
|
label: "#F0EDE8",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function ProfileTab() {
|
function ProfileTab() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
return user ? <Profile /> : <AuthGate />;
|
return user ? <Profile /> : <AuthGate />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const scheme = useColorScheme();
|
const scheme = useColorScheme();
|
||||||
const dark = scheme === "dark";
|
const dark = scheme === "dark";
|
||||||
@@ -53,6 +82,11 @@ export default function App() {
|
|||||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||||
|
|
||||||
|
const { updateInfo } = useUpdatecheck();
|
||||||
|
const [updateDismissed, setUpdateDismissed] = useState(false);
|
||||||
|
|
||||||
|
usePushNotifications();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
@@ -117,8 +151,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</SafeAreaProvider>
|
|
||||||
|
|
||||||
|
{/* Overlays the entire app including the tab bar */}
|
||||||
|
{updateInfo && !updateDismissed && (
|
||||||
|
<UpdatePrompt
|
||||||
|
updateInfo={updateInfo}
|
||||||
|
onDismiss={() => setUpdateDismissed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</SafeAreaProvider>
|
||||||
</AuthProvider>
|
</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",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@notifee/react-native": "^9.1.8",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-firebase/app": "^24.0.0",
|
||||||
|
"@react-native-firebase/messaging": "^24.0.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-tailwindcss": "^1.1.11",
|
"react-native-tailwindcss": "^1.1.11",
|
||||||
|
"react-native-toast-message": "^2.3.3",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"tailwindcss": "^3.4.18"
|
"tailwindcss": "^3.4.18"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, {useCallback, useState} from "react";
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView, TouchableOpacity,
|
View, Text, ScrollView, TouchableOpacity,
|
||||||
ActivityIndicator, useColorScheme,
|
ActivityIndicator, useColorScheme,
|
||||||
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import CollapsibleCategory from "../components/CollapsibleCategory";
|
import CollapsibleCategory from "../components/CollapsibleCategory";
|
||||||
|
import {useFocusEffect} from "expo-router";
|
||||||
|
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
@@ -40,6 +41,12 @@ export default function AllFeed() {
|
|||||||
setTimeout(() => setRefreshing(false), 800);
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
handleRefresh();
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -25,13 +25,19 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
const accent = dark ? "#E07B45" : "#C4622D";
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
const trimmedPassword = password.trim();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
if (!email || !password) { setError("Please fill in all fields."); return; }
|
if (!trimmedEmail || !trimmedPassword) { setError("Please fill in all fields."); return; }
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "login") await login({ email, password });
|
if (mode === "login") await login({ email:trimmedEmail, password:trimmedPassword});
|
||||||
else await register({ email, password, name });
|
else await register({ email: trimmedEmail,password:trimmedPassword , name });
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message ?? "Something went wrong.");
|
setError(e.message ?? "Something went wrong.");
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type SettingRowProps = {
|
|||||||
accent?: string;
|
accent?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
||||||
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
|||||||
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||||
const [showSubs, setShowSubs] = useState(false);
|
const [showSubs, setShowSubs] = useState(false);
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const { user } = useAuth();
|
const { user, updateNotificationType } = useAuth();
|
||||||
const [notifications, setNotifications] = React.useState(true);
|
const [notifications, setNotifications] = useState(
|
||||||
|
user?.notificationType === 'PUSH_NOTIFICATION'
|
||||||
|
);
|
||||||
const [digest, setDigest] = React.useState(false);
|
const [digest, setDigest] = React.useState(false);
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
|
const handleNotificationToggle = async (value: boolean) => {
|
||||||
|
setNotifications(value);
|
||||||
|
try {
|
||||||
|
await updateNotificationType(
|
||||||
|
value ? 'PUSH_NOTIFICATION' : 'NO_NOTIFICATION'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setNotifications(!value); // revert on failure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||||
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
|||||||
>
|
>
|
||||||
<SectionLabel title="Notifications" />
|
<SectionLabel title="Notifications" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="notifications-outline" label="Push notifications (TODO)"
|
icon="notifications-outline"
|
||||||
toggle toggleValue={notifications} onToggle={setNotifications}
|
label="Push notifications"
|
||||||
|
toggle
|
||||||
|
toggleValue={notifications}
|
||||||
|
onToggle={handleNotificationToggle}
|
||||||
accent={dark ? "#E07B45" : "#C4622D"}
|
accent={dark ? "#E07B45" : "#C4622D"}
|
||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="mail-outline" label="Daily digest email (TODO)"
|
icon="mail-outline"
|
||||||
toggle toggleValue={digest} onToggle={setDigest}
|
label="Weekly Digest email (TODO)"
|
||||||
|
toggle
|
||||||
|
toggleValue={digest}
|
||||||
|
onToggle={setDigest}
|
||||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -231,32 +253,21 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
|||||||
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
||||||
<EditProfile onClose={() => setShowEdit(false)} />
|
<EditProfile onClose={() => setShowEdit(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
||||||
<HelpSupport onClose={() => setShowHelp(false)} />
|
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<Modal
|
<Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
|
||||||
visible={showSubs}
|
|
||||||
animationType="slide"
|
|
||||||
presentationStyle="pageSheet"
|
|
||||||
onRequestClose={() => setShowSubs(false)}
|
|
||||||
>
|
|
||||||
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
|
|||||||
import ExpandableItem from "../components/ExpandableItem";
|
import ExpandableItem from "../components/ExpandableItem";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Entry, entriesApi } from "../services/api";
|
import { Entry, entriesApi } from "../services/api";
|
||||||
|
import {useFocusEffect} from "expo-router";
|
||||||
|
|
||||||
type SortMode = "time" | "subject";
|
type SortMode = "time" | "subject";
|
||||||
|
|
||||||
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [user?.subscribedSubjectIds]);
|
}, [user?.subscribedSubjectIds?.join(',')]);
|
||||||
|
|
||||||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
// Always re-fetch when tab gains focus
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
fetchFeed();
|
||||||
|
}, [fetchFeed])
|
||||||
|
);
|
||||||
|
|
||||||
// Filter then group
|
// Filter then group
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
|
|||||||
<View className="items-center mt-16 px-8">
|
<View className="items-center mt-16 px-8">
|
||||||
<Text className="text-4xl mb-3">🔍</Text>
|
<Text className="text-4xl mb-3">🔍</Text>
|
||||||
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
No results for "{query}"
|
No results for "{query}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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) {
|
function authHeader(token?: string) {
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
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>(
|
async function request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -25,7 +33,14 @@ async function request<T>(
|
|||||||
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
||||||
...(body ? { body: JSON.stringify(body) } : {}),
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||||
|
console.log('Failed URL:', res.url);
|
||||||
|
console.log('Status:', res.status);
|
||||||
|
console.log('Response:', errorText);
|
||||||
|
if (res.status === 401 && token) onUnauthorized?.();
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +80,7 @@ export type UserDTO = {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
subjectSet: Subject[];
|
subjectSet: Subject[];
|
||||||
|
notificationType: NotificationType;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,30 +119,46 @@ export type RegisterPayload = { email: string; password: string; name?: string }
|
|||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||||
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||||
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
|
|
||||||
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body),
|
getUser: (token: string) =>
|
||||||
|
get<UserDTO>("/users/me", token),
|
||||||
|
|
||||||
|
updateUser: (body: UserUpdateDTO, token: string) =>
|
||||||
|
request<UserDTO>("PUT", "/users/me", token, body),
|
||||||
|
|
||||||
|
deleteUser: (token: string) =>
|
||||||
|
del<void>("/users/me", token),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
updateNotificationType: (
|
||||||
|
type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION',
|
||||||
|
token: string
|
||||||
|
) => post<void>('/users/me/notification-type', { notificationType: type }, token),
|
||||||
|
};
|
||||||
|
|
||||||
export const subjectsApi = {
|
export const subjectsApi = {
|
||||||
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const subscriptionsApi = {
|
export const subscriptionsApi = {
|
||||||
subscribe: (userId: string, subjectId: string, token: string) =>
|
subscribe: (subjectId: string, token: string) =>
|
||||||
put<UserDTO>(
|
put<UserDTO>(
|
||||||
`/users/${userId}/subjects/${subjectId}`,
|
`/users/me/subjects/${subjectId}`,
|
||||||
{}, // body placeholder
|
{},
|
||||||
token
|
token
|
||||||
),
|
),
|
||||||
|
|
||||||
unsubscribe: (userId: string, subjectId: string, token: string) =>
|
unsubscribe: (subjectId: string, token: string) =>
|
||||||
del<UserDTO>(
|
del<UserDTO>(
|
||||||
`/users/${userId}/subjects/${subjectId}`,
|
`/users/me/subjects/${subjectId}`,
|
||||||
token
|
token
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const entriesApi = {
|
export const entriesApi = {
|
||||||
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||||
get<SpringPage<Entry>>("/entries", undefined, params),
|
get<SpringPage<Entry>>("/entries", undefined, params),
|
||||||
|
|||||||
@@ -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