Compare commits
39 Commits
d6657d83dd
...
mobile-v6
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e57181a9 | |||
| ce01ab763c | |||
| dcf65840c0 | |||
| 194d598d6d | |||
| d76434014f | |||
| 266802088a | |||
| 6c98e4a469 | |||
| 5d2898dd13 | |||
| 694b68cbc5 | |||
| 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,198 @@
|
||||
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"
|
||||
|
||||
mobile-release:
|
||||
name: Mobile Release
|
||||
needs: backend-unit-tests
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Accept Android SDK licenses
|
||||
run: yes | sdkmanager --licenses > /dev/null || true
|
||||
|
||||
- name: Determine next mobile version
|
||||
id: version
|
||||
run: |
|
||||
git fetch --tags
|
||||
LAST=$(git tag -l 'mobile-v*' | sed 's/mobile-v//' | sort -n | tail -1)
|
||||
NEXT=$(( ${LAST:-0} + 1 ))
|
||||
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=mobile-v$NEXT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stamp app version
|
||||
run: node scripts/set-mobile-version.js ${{ steps.version.outputs.next }}
|
||||
|
||||
- name: Restore Firebase config
|
||||
run: echo "${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}" | base64 -d > google-services.json
|
||||
|
||||
- name: Restore env
|
||||
run: echo "EXPO_PUBLIC_API_URL=${{ secrets.EXPO_PUBLIC_API_URL }}" > .env
|
||||
|
||||
- name: Restore signing keystore
|
||||
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ksan.dev.keystore
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Prebuild Android project
|
||||
run: npx expo prebuild -p android --no-install
|
||||
|
||||
- name: Increase Gradle memory
|
||||
run: |
|
||||
echo "org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g" >> android/gradle.properties
|
||||
|
||||
|
||||
- name: Build signed release APK
|
||||
working-directory: frontend/android
|
||||
run: |
|
||||
./gradlew assembleRelease --no-daemon \
|
||||
-Pandroid.injected.signing.store.file="$PWD/../ksan.dev.keystore" \
|
||||
-Pandroid.injected.signing.store.password="${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" \
|
||||
-Pandroid.injected.signing.key.alias="${{ secrets.ANDROID_KEY_ALIAS }}" \
|
||||
-Pandroid.injected.signing.key.password="${{ secrets.ANDROID_KEY_PASSWORD }}"
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
APK=$(find android/app/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||
OUT="etfoglasi-${{ steps.version.outputs.tag }}.apk"
|
||||
cp "$APK" "$OUT"
|
||||
echo "path=$OUT" >> "$GITHUB_OUTPUT"
|
||||
echo "name=$OUT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea release
|
||||
run: |
|
||||
API="https://git.${{ secrets.DOMAIN }}/api/v1/repos/${{ secrets.REGISTRY_USER }}/etf-oglasi"
|
||||
AUTH="Authorization: token ${{ secrets.GITEARELEASES_TOKEN }}"
|
||||
|
||||
HTTP_CODE=$(curl -s -o release.json -w "%{http_code}" -X POST \
|
||||
-H "$AUTH" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${{ steps.version.outputs.tag }}\",\"target_commitish\":\"${{ github.sha }}\",\"name\":\"${{ steps.version.outputs.tag }}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"$API/releases")
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat release.json
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_ID=$(node -pe "JSON.parse(require('fs').readFileSync('release.json','utf8')).id")
|
||||
|
||||
HTTP_CODE=$(curl -s -o asset.json -w "%{http_code}" -X POST \
|
||||
-H "$AUTH" \
|
||||
-F "attachment=@${{ steps.apk.outputs.path }}" \
|
||||
"$API/releases/$RELEASE_ID/assets?name=${{ steps.apk.outputs.name }}")
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat asset.json
|
||||
|
||||
if [ "$HTTP_CODE" != "201" ]; then
|
||||
echo "Failed to upload asset"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,18 +1,8 @@
|
||||
backend/.db/
|
||||
backend/.env
|
||||
backend/todo
|
||||
backend/.creds
|
||||
backend/init/*
|
||||
backend/*temp.java
|
||||
backend/src/main/java/dev/ksan/etfoglasiserver/temp.java
|
||||
########################
|
||||
# GLOBAL (all projects)
|
||||
########################
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Java / Gradle
|
||||
backend/.gradle/
|
||||
backend/build/
|
||||
todo
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
@@ -21,7 +11,33 @@ backend/build/
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Eclipse / STS
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
########################
|
||||
# BACKEND (Spring Boot)
|
||||
########################
|
||||
|
||||
backend/.gradle/
|
||||
backend/build/
|
||||
backend/bin/
|
||||
backend/src/main/resources/application.properties
|
||||
|
||||
backend/.env
|
||||
backend/.creds
|
||||
backend/.db/
|
||||
|
||||
backend/init/*
|
||||
backend/**/*temp.java
|
||||
|
||||
backend/src/main/resources/google-services.json
|
||||
backend/src/main/resources/etf-oglasi-firebase.json
|
||||
backend/src/main/resources/firebase.json
|
||||
|
||||
# Java / Eclipse / STS
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
@@ -34,54 +50,45 @@ nbbuild/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
# Environment & secrets
|
||||
backend/.env
|
||||
backend/.creds
|
||||
########################
|
||||
# FRONTEND (React Native / Expo)
|
||||
########################
|
||||
|
||||
# Local database
|
||||
backend/.db/
|
||||
frontend/node_modules/
|
||||
|
||||
## frontend
|
||||
frontend/google-services.json
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
frontend/.expo/
|
||||
frontend/dist/
|
||||
frontend/web-build/
|
||||
|
||||
frontend/.kotlin/
|
||||
frontend/*.orig.*
|
||||
frontend/*.jks
|
||||
frontend/*.p8
|
||||
frontend/*.p12
|
||||
frontend/*.key
|
||||
frontend/*.mobileprovision
|
||||
|
||||
frontend/.metro-health-check*
|
||||
|
||||
frontend/npm-debug.*
|
||||
frontend/yarn-debug.*
|
||||
frontend/yarn-error.*
|
||||
|
||||
frontend/.env
|
||||
frontend/.env*.local
|
||||
|
||||
frontend/*.tsbuildinfo
|
||||
|
||||
# Native builds
|
||||
frontend/ios/
|
||||
frontend/android/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
frontend/expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
frontend/.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
CLAUDE.md
|
||||
ksan.dev.keystore
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ETF Oglasi
|
||||
|
||||
<img src="logo.png" alt="ETF Oglasi logo" width="150" />
|
||||
|
||||
A hobby full-stack project I built to learn modern app development end to end.
|
||||
|
||||
It scrapes the announcement boards of the Faculty of Electrical Engineering,
|
||||
University of Banja Luka, stores the announcements in PostgreSQL, and lets
|
||||
students browse and subscribe to subjects from a mobile app. Subscribers get
|
||||
push notifications when new announcements are posted.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Spring Boot 3.5 (Java 21), REST API, PostgreSQL
|
||||
- **Frontend:** React Native / Expo (Expo Router, NativeWind)
|
||||
- **Notifications:** Firebase Cloud Messaging
|
||||
- **Tooling:** Docker, Gitea Actions for CI/CD, deployed to a self-hosted home server
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `backend/` — Spring Boot REST API and the scraper
|
||||
- `frontend/` — Expo mobile app
|
||||
|
||||
## Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp src/main/resources/application.properties.example src/main/resources/application.properties # fill in datasource + jwt.secret
|
||||
docker compose up -d # local Postgres
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
Run tests: `./gradlew test`
|
||||
|
||||
## Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
# set EXPO_PUBLIC_API_URL in .env to the backend base URL
|
||||
npm start
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Semi-actively developed as a learning project. It is an app I actually use, and
|
||||
maybe others will find it useful too.
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +39,19 @@ dependencies {
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
|
||||
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
|
||||
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
|
||||
implementation("org.projectlombok:lombok:1.18.42")
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||
implementation 'com.google.firebase:firebase-admin:9.9.0'
|
||||
|
||||
|
||||
|
||||
testImplementation 'org.springframework.security:spring-security-test'
|
||||
testRuntimeOnly 'com.h2database:h2'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation("org.testcontainers:postgresql:1.21.3")
|
||||
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
|
||||
testImplementation("org.mockito:mockito-inline:5.2.0")
|
||||
testImplementation 'org.mockito:mockito-core'
|
||||
}
|
||||
|
||||
generateJava {
|
||||
@@ -49,4 +62,8 @@ generateJava {
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
|
||||
jvmArgs += [
|
||||
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('byte-buddy-agent') }}"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ services:
|
||||
image: 'postgres:16'
|
||||
container_name: etfo-db
|
||||
environment:
|
||||
- 'POSTGRES_DB=etfo-db'
|
||||
- 'POSTGRES_PASSWORD=test'
|
||||
- 'POSTGRES_USER=test'
|
||||
- POSTGRES_DB=etfo-db
|
||||
- POSTGRES_PASSWORD=test
|
||||
- POSTGRES_USER=test
|
||||
volumes:
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
- ./.db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- '5432:5432'
|
||||
- 5432:5432
|
||||
|
||||
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
|
||||
CONSTRAINT subjects_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public."user-subject"
|
||||
CREATE TABLE IF NOT EXISTS public."user_subject"
|
||||
(
|
||||
user_id uuid NOT NULL,
|
||||
subject_id uuid NOT NULL,
|
||||
@@ -50,11 +50,37 @@ CREATE TABLE IF NOT EXISTS public.users
|
||||
email character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||
password character varying(255) COLLATE pg_catalog."default" NOT NULL,
|
||||
reg_time timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'NO_NOTIFICATION'::character varying,
|
||||
notification_type character varying(255) COLLATE pg_catalog."default" NOT NULL DEFAULT 'PUSH_NOTIFICATION'::character varying,
|
||||
CONSTRAINT users_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT unique_email UNIQUE (email)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.device_tokens
|
||||
(
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL,
|
||||
fcm_token text NOT NULL,
|
||||
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT device_tokens_pkey PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT fk_device_tokens_user
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES public.users(id)
|
||||
ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT uq_device_tokens_fcm_token
|
||||
UNIQUE (fcm_token)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_device_tokens_user_id
|
||||
ON public.device_tokens(user_id);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
ALTER TABLE IF EXISTS public.alt_subject
|
||||
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
|
||||
REFERENCES public.subjects (id) MATCH SIMPLE
|
||||
|
||||
@@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class EtfoglasiServerApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package dev.ksan.etfoglasiserver.config;
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.FirebaseOptions;
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class FirebaseConfig {
|
||||
|
||||
@Value("${firebase.credentials.path}")
|
||||
private String credentialsPath;
|
||||
|
||||
@Bean
|
||||
public FirebaseApp firebaseApp() throws IOException {
|
||||
GoogleCredentials credentials = GoogleCredentials
|
||||
.fromStream(new FileInputStream(credentialsPath))
|
||||
.createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging"));
|
||||
|
||||
FirebaseOptions options = FirebaseOptions.builder()
|
||||
.setCredentials(credentials)
|
||||
.build();
|
||||
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
return FirebaseApp.initializeApp(options);
|
||||
}
|
||||
return FirebaseApp.getInstance();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) {
|
||||
return FirebaseMessaging.getInstance(firebaseApp);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
|
||||
|
||||
import dev.ksan.etfoglasiserver.service.JWTService;
|
||||
import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
|
||||
import dev.ksan.etfoglasiserver.service.UserService;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -33,9 +33,15 @@ public class JwtFilter extends OncePerRequestFilter {
|
||||
String token = null;
|
||||
String username = null;
|
||||
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
token = authHeader.substring(7);
|
||||
try {
|
||||
username = jwtService.extractEmail(token);
|
||||
} catch (JwtException e) {
|
||||
// expired, malformed, or otherwise invalid token - treat as unauthenticated
|
||||
username = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
@@ -13,8 +14,14 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -29,19 +36,39 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
|
||||
return http.csrf(customizer -> customizer.disable()).
|
||||
authorizeHttpRequests(request -> request
|
||||
return http
|
||||
.csrf(customizer -> customizer.disable())
|
||||
.cors(cors -> {})
|
||||
.authorizeHttpRequests(request -> request
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/login", "/api/register").permitAll()
|
||||
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
|
||||
.anyRequest().authenticated()).
|
||||
httpBasic(Customizer.withDefaults()).
|
||||
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
||||
config.setAllowedOrigins(List.of(
|
||||
"http://localhost:8081",
|
||||
"https://etf-oglasi.ksan.dev"
|
||||
));
|
||||
|
||||
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,4 +88,10 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.ksan.etfoglasiserver.controller;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
|
||||
import dev.ksan.etfoglasiserver.service.DeviceTokenService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/device-tokens")
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceTokenController {
|
||||
|
||||
private final DeviceTokenService deviceTokenService;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<Void> register(
|
||||
@RequestBody RegisterTokenRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
|
||||
deviceTokenService.registerToken(
|
||||
principal.getUsername(),
|
||||
request.getToken());
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -23,7 +25,7 @@ public class EntryController {
|
||||
return service.getEntryById(entryId);
|
||||
}
|
||||
|
||||
//ignore this for now
|
||||
// TODO only for testing
|
||||
@PostMapping("/entries")
|
||||
public void addEntry(@RequestBody EntryDTO entry) {
|
||||
service.addEntry(entry);
|
||||
@@ -36,23 +38,38 @@ public class EntryController {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@GetMapping("/entries")
|
||||
public Page<Entry> getEntries(
|
||||
@RequestParam(required = false) String subjectId,
|
||||
@RequestParam(required = false) String groupName,
|
||||
@RequestParam(defaultValue = "0") int page
|
||||
) {
|
||||
UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
||||
// UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
|
||||
|
||||
UUID parsedSubjectId = null;
|
||||
if (subjectId != null) {
|
||||
try{
|
||||
parsedSubjectId = UUID.fromString(subjectId);
|
||||
}catch (IllegalArgumentException e){
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid subject ID");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return service.getEntries(parsedSubjectId, groupName, page);
|
||||
}
|
||||
|
||||
@PutMapping("/entries")
|
||||
public void updateEntry(@RequestBody EntryDTO entry) {
|
||||
service.updateEntry(entry);
|
||||
}
|
||||
|
||||
@DeleteMapping("/entries/{entryId}")
|
||||
public void deleteEntry(@PathVariable String entryId) {
|
||||
service.deleteEntry(entryId);
|
||||
}
|
||||
// @PutMapping("/entries")
|
||||
// public void updateEntry(@RequestBody EntryDTO entry) {
|
||||
// service.updateEntry(entry);
|
||||
// }
|
||||
|
||||
// @DeleteMapping("/entries/{entryId}")
|
||||
// public void deleteEntry(@PathVariable String entryId) {
|
||||
// service.deleteEntry(entryId);
|
||||
// }
|
||||
//
|
||||
}
|
||||
|
||||
@@ -23,13 +23,13 @@ public class SubjectAltController {
|
||||
return service.getSubjectAlt(subjectId);
|
||||
}
|
||||
|
||||
@PostMapping("/subjectalt")
|
||||
public void addSubject(@RequestBody SubjectAlt subject) {
|
||||
service.addSubjectAlt(subject);
|
||||
}
|
||||
|
||||
@DeleteMapping("/subjectalt/{subjectId}")
|
||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
service.deleteSubjectAlt(subjectId);
|
||||
}
|
||||
// @PostMapping("/subjectalt")
|
||||
// public void addSubject(@RequestBody SubjectAlt subject) {
|
||||
// service.addSubjectAlt(subject);
|
||||
// }
|
||||
//
|
||||
// @DeleteMapping("/subjectalt/{subjectId}")
|
||||
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
// service.deleteSubjectAlt(subjectId);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -22,18 +22,21 @@ public class SubjectController {
|
||||
return service.getSubject(subjectId);
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/subjects")
|
||||
public void addSubject(@RequestBody Subject subject) {
|
||||
service.addSubject(subject);
|
||||
}
|
||||
|
||||
@PutMapping("/subjects")
|
||||
public void updateSubject(@RequestBody Subject subject) {
|
||||
service.updateSubject(subject);
|
||||
}
|
||||
//TODO uncomment this if ever roles are added so only admin should modify this (didnt really feel like doing it rn)
|
||||
//
|
||||
// @PutMapping("/subjects")
|
||||
// public void updateSubject(@RequestBody Subject subject) {
|
||||
// service.updateSubject(subject);
|
||||
// }
|
||||
|
||||
@DeleteMapping("/subjects/{subjectId}")
|
||||
public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
service.deleteSubject(subjectId);
|
||||
}
|
||||
// @DeleteMapping("/subjects/{subjectId}")
|
||||
// public void deleteSubject(@PathVariable UUID subjectId) {
|
||||
// service.deleteSubject(subjectId);
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.service.UserService;
|
||||
import java.util.List;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -20,76 +24,106 @@ public class UserController {
|
||||
@Autowired UserService service;
|
||||
|
||||
|
||||
@GetMapping("users/{userId}")
|
||||
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID userId) {
|
||||
@GetMapping("/users/me")
|
||||
public ResponseEntity<UserDTO> getUserById(Authentication authentication) {
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO user = service.getUserById(userId);
|
||||
User user = service.getAccountByEmail(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "User not found"
|
||||
));
|
||||
|
||||
if (user != null) {
|
||||
return new ResponseEntity<>(user, HttpStatus.OK);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
|
||||
return ResponseEntity.ok(new UserDTO(user));
|
||||
}
|
||||
|
||||
@PutMapping("/users")
|
||||
public ResponseEntity<UserDTO> updateUser(@RequestBody UserCreationDTO user) {
|
||||
@PutMapping("/users/me")
|
||||
public ResponseEntity<UserDTO> updateUser(
|
||||
@RequestBody UserCreationDTO user,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO userUpdated = service.updateUser(user);
|
||||
if (userUpdated != null) {
|
||||
return new ResponseEntity<>(userUpdated, HttpStatus.OK);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO userUpdated = service.updateUser(email, user);
|
||||
|
||||
return ResponseEntity.ok(userUpdated);
|
||||
}
|
||||
|
||||
@PutMapping("/users/{userId}/subjects/{subjectId}")
|
||||
|
||||
|
||||
@PostMapping("/users/me/notification-type")
|
||||
public ResponseEntity<?> updateNotificationType(
|
||||
@RequestBody Map<String, String> request,
|
||||
Authentication authentication) {
|
||||
|
||||
String email = authentication.getName();
|
||||
NotificationMethod type = NotificationMethod.valueOf(request.get("notificationType"));
|
||||
service.updateNotificationType(email, type);
|
||||
|
||||
return ResponseEntity.ok(Map.of("status", "updated"));
|
||||
}
|
||||
|
||||
@PutMapping("/users/me/subjects/{subjectId}")
|
||||
public ResponseEntity<UserDTO> addSubject(
|
||||
@PathVariable UUID userId,
|
||||
@PathVariable UUID subjectId
|
||||
) {
|
||||
@PathVariable UUID subjectId,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO updatedUser = service.addSubject(userId, subjectId);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO updatedUser = service.addSubject(email, subjectId);
|
||||
|
||||
return ResponseEntity.ok(updatedUser);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{userId}/subjects/{subjectId}")
|
||||
@DeleteMapping("/users/me/subjects/{subjectId}")
|
||||
public ResponseEntity<UserDTO> removeSubject(
|
||||
@PathVariable UUID userId,
|
||||
@PathVariable UUID subjectId
|
||||
) {
|
||||
@PathVariable UUID subjectId,
|
||||
Authentication authentication) {
|
||||
|
||||
UserDTO updatedUser = service.removeSubject(userId, subjectId);
|
||||
String email = authentication.getName();
|
||||
|
||||
UserDTO updatedUser = service.removeSubject(email, subjectId);
|
||||
|
||||
return ResponseEntity.ok(updatedUser);
|
||||
}
|
||||
|
||||
// not the best, should fix this later
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
|
||||
try {
|
||||
JwtResponseDTO userVer = service.verify(user);
|
||||
|
||||
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
|
||||
}catch (Exception e) {
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// not the best, should fix this later too
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
|
||||
|
||||
try {
|
||||
UserDTO newUser = service.register(user);
|
||||
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED);
|
||||
}catch (Exception e) {
|
||||
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
//
|
||||
// @DeleteMapping("/users/{userId}")
|
||||
// public void deleteUser(@PathVariable UUID userId) {
|
||||
// service.deleteUser(userId);
|
||||
// }
|
||||
|
||||
/*
|
||||
@PostMapping("/users")
|
||||
public void addUser(@RequestBody UserCreationDTO user) {
|
||||
NotificationMethod method = NotificationMethod.valueOf(user.getNotification());
|
||||
user.setNotificationMethod(method);
|
||||
service.addUser(user);
|
||||
|
||||
|
||||
@DeleteMapping("/users/me")
|
||||
public ResponseEntity<Void> deleteUser(Authentication authentication) {
|
||||
|
||||
String email = authentication.getName();
|
||||
|
||||
service.deleteUser(email);
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ public class UserCreationDTO {
|
||||
private Set<Subject> subjectSet;
|
||||
private String notification;
|
||||
|
||||
public UserCreationDTO(String mail, String password) {
|
||||
this.email = mail;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,26 @@ package dev.ksan.etfoglasiserver.dto;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class UserDTO {
|
||||
@Setter
|
||||
@Getter
|
||||
private UUID id;
|
||||
@Setter
|
||||
@Getter
|
||||
private String email;
|
||||
@Setter
|
||||
@Getter
|
||||
private Set<Subject> subjectSet;
|
||||
@Getter
|
||||
@Setter
|
||||
private NotificationMethod notificationType;
|
||||
|
||||
public UserDTO() {}
|
||||
|
||||
@@ -17,31 +30,17 @@ public class UserDTO {
|
||||
this.id = id;
|
||||
this.email = email;
|
||||
this.subjectSet = subjectSet;
|
||||
this.notificationType = notificationMethod2;
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
public UserDTO(User user) {
|
||||
this.id = user.getId();
|
||||
this.email = user.getEmail();
|
||||
this.subjectSet = user.getSubjectSet();
|
||||
this.notificationType = user.getNotificationMethod();
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public Set<Subject> getSubjectSet() {
|
||||
return subjectSet;
|
||||
}
|
||||
|
||||
public void setSubjectSet(Set<Subject> subjectSet) {
|
||||
this.subjectSet = subjectSet;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
public class UserLoginDTO {
|
||||
|
||||
private String email;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter // ← add class-level getter
|
||||
@Entity
|
||||
@Table(
|
||||
name = "device_tokens",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = "fcm_token")
|
||||
}
|
||||
)
|
||||
public class DeviceToken {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
@Setter
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Setter
|
||||
@Column(name = "fcm_token", nullable = false)
|
||||
private String fcmToken;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,5 @@ package dev.ksan.etfoglasiserver.model;
|
||||
public enum NotificationMethod {
|
||||
|
||||
NO_NOTIFICATION,
|
||||
EMAIL,
|
||||
PUSH_NOTIFICATION
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class RegisterTokenRequest {
|
||||
private String token;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.ksan.etfoglasiserver.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
@@ -9,13 +11,18 @@ import java.util.UUID;
|
||||
@Table(name = "subjects")
|
||||
public class Subject {
|
||||
|
||||
@Getter
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
@Column(columnDefinition = "uuid")
|
||||
private UUID id;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Column private long code;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@@ -26,23 +33,4 @@ public class Subject {
|
||||
public Subject(){
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(long code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -18,9 +20,11 @@ public class SubjectAlt {
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
private UUID id;
|
||||
|
||||
@Setter
|
||||
@Column(nullable = false)
|
||||
String name;
|
||||
|
||||
@Setter
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(nullable = false)
|
||||
private Subject subject;
|
||||
@@ -32,22 +36,15 @@ public class SubjectAlt {
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public Subject getSubject() {
|
||||
return subject;
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
public void setSubject(Subject subject) {
|
||||
this.subject = subject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package dev.ksan.etfoglasiserver.model;
|
||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -24,18 +26,40 @@ public class User {
|
||||
nullable = false,
|
||||
updatable = false,
|
||||
insertable = false,
|
||||
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
|
||||
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||
)
|
||||
private LocalDateTime regTime;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "notification_type", nullable = false)
|
||||
private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION;
|
||||
private NotificationMethod notificationType =
|
||||
NotificationMethod.PUSH_NOTIFICATION;
|
||||
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "subject_id"))
|
||||
@JoinTable(
|
||||
name = "user_subject",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "subject_id")
|
||||
)
|
||||
private Set<Subject> subjectSet;
|
||||
|
||||
@OneToMany(
|
||||
mappedBy = "user",
|
||||
cascade = CascadeType.ALL,
|
||||
orphanRemoval = true,
|
||||
fetch = FetchType.LAZY
|
||||
)
|
||||
private List<DeviceToken> deviceTokens = new ArrayList<>();
|
||||
|
||||
public void addDeviceToken(DeviceToken token) {
|
||||
deviceTokens.add(token);
|
||||
token.setUser(this);
|
||||
}
|
||||
|
||||
public void removeDeviceToken(DeviceToken token) {
|
||||
deviceTokens.remove(token);
|
||||
token.setUser(null);
|
||||
}
|
||||
public User() {}
|
||||
|
||||
public User(UserCreationDTO user) {
|
||||
@@ -89,4 +113,5 @@ public class User {
|
||||
public NotificationMethod getNotificationMethod() {
|
||||
return notificationType;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.ksan.etfoglasiserver.repository;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.Entry;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface DeviceTokenRepo extends JpaRepository<DeviceToken, UUID> {
|
||||
|
||||
List<DeviceToken> findByUserId(UUID userId);
|
||||
|
||||
Optional<DeviceToken> findByFcmToken(String fcmToken);
|
||||
|
||||
void deleteByFcmToken(String fcmToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.DeviceToken;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
|
||||
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceTokenService {
|
||||
|
||||
private final DeviceTokenRepo deviceTokenRepo;
|
||||
private final UserRepo userRepo;
|
||||
|
||||
public void registerToken(String email, String token) {
|
||||
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
DeviceToken deviceToken = deviceTokenRepo
|
||||
.findByFcmToken(token)
|
||||
.orElse(new DeviceToken());
|
||||
|
||||
deviceToken.setUser(user);
|
||||
deviceToken.setFcmToken(token);
|
||||
|
||||
deviceTokenRepo.save(deviceToken);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public class EntryService {
|
||||
Entry newEntry = new Entry(entry);
|
||||
newEntry.setSubject(subject);
|
||||
entryRepo.save(newEntry);
|
||||
|
||||
subjectService.notifyAsync(newEntry);
|
||||
}
|
||||
|
||||
public void addEntry(Entry entry) {
|
||||
@@ -63,7 +65,7 @@ public class EntryService {
|
||||
System.out.println("Duplicate");
|
||||
return;
|
||||
}
|
||||
subjectService.notify(entry);
|
||||
subjectService.notifyAsync(entry);
|
||||
}
|
||||
|
||||
public void updateEntry(EntryDTO entry) {
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class JWTService {
|
||||
|
||||
//TODO add persistent token env variable
|
||||
private String secretKey = "";
|
||||
@Value("${jwt.secret}")
|
||||
private String secretKey;
|
||||
|
||||
|
||||
private JWTService() {
|
||||
try {
|
||||
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
|
||||
SecretKey secKey = keyGen.generateKey();
|
||||
secretKey = Base64.getEncoder().encodeToString(secKey.getEncoded());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateToken(String email) {
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
|
||||
|
||||
return Jwts.builder().claims().add(claims).subject(email)
|
||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300))
|
||||
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
|
||||
.and().signWith(getKey()).compact();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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) {
|
||||
|
||||
// Include a notification block so Android's system tray displays it
|
||||
// even when the app is killed/dozing (a data-only message is only
|
||||
// ever shown by the app's own JS handler, which won't run when the
|
||||
// OS has force-stopped the app). HIGH priority tells FCM to deliver
|
||||
// promptly and wake the device out of Doze.
|
||||
Message message = Message.builder()
|
||||
.setToken(token.getFcmToken())
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(title)
|
||||
.setBody(body)
|
||||
.build())
|
||||
.setAndroidConfig(AndroidConfig.builder()
|
||||
.setPriority(AndroidConfig.Priority.HIGH)
|
||||
.build())
|
||||
.putData("title", title)
|
||||
.putData("body", body)
|
||||
.build();
|
||||
|
||||
try {
|
||||
String id = firebaseMessaging.send(message);
|
||||
System.out.println("Send:" + id);
|
||||
} catch (FirebaseMessagingException ex) {
|
||||
|
||||
ex.printStackTrace();
|
||||
if (ex.getMessagingErrorCode()
|
||||
== MessagingErrorCode.UNREGISTERED) {
|
||||
deviceTokenRepo.delete(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.ksan.etfoglasiserver.service;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingException;
|
||||
import dev.ksan.etfoglasiserver.model.Entry;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
@@ -8,22 +9,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import dev.ksan.etfoglasiserver.repository.UserRepo;
|
||||
import org.simplejavamail.api.mailer.Mailer;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SubjectService {
|
||||
|
||||
@Autowired private SubjectRepo subjectRepo;
|
||||
|
||||
@Autowired
|
||||
private UserRepo userRepo;
|
||||
private NotificationService notificationService;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private MailService mailService;
|
||||
|
||||
|
||||
public List<Subject> getSubjects() {
|
||||
return subjectRepo.findAll();
|
||||
@@ -67,29 +68,36 @@ public class SubjectService {
|
||||
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
|
||||
}
|
||||
|
||||
public void notify(Entry entry) {
|
||||
List<User> users = userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||
|
||||
//TODO move this and make an EventListener for this
|
||||
|
||||
@Async
|
||||
public void notifyAsync(Entry entry) {
|
||||
|
||||
// The scraper couldn't always match an announcement to a known subject, in
|
||||
// which case there's nobody to notify. Guard against the NPE that would
|
||||
// otherwise be thrown (and silently swallowed) on this async thread.
|
||||
if (entry.getSubject() == null) return;
|
||||
|
||||
List<User> users =
|
||||
userService.findUsersBySubjectId(entry.getSubject().getId());
|
||||
|
||||
if (users == null || users.isEmpty()) return;
|
||||
|
||||
for (User user : users) {
|
||||
if(user.getNotificationMethod() == NotificationMethod.EMAIL)
|
||||
this.sendEmailNotification(entry, user);
|
||||
if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION)
|
||||
this.sendPushNotification(entry, user);
|
||||
System.out.println(user.getEmail());
|
||||
|
||||
if (user.getNotificationMethod() != NotificationMethod.PUSH_NOTIFICATION)
|
||||
continue;
|
||||
|
||||
try{
|
||||
|
||||
notificationService.sendToUser(user, entry.getTitle(), entry.getParagraph());
|
||||
}catch (FirebaseMessagingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void sendEmailNotification(Entry entry,User user) {
|
||||
|
||||
mailService.sendEmail(user.getEmail(),entry.getTitle(),entry.getParagraph());
|
||||
}
|
||||
|
||||
//todo
|
||||
public void sendPushNotification(Entry entry,User user) {
|
||||
System.out.println("Sending push notification");
|
||||
}
|
||||
|
||||
public long count() {
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserDTO;
|
||||
import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
|
||||
import dev.ksan.etfoglasiserver.model.NotificationMethod;
|
||||
import dev.ksan.etfoglasiserver.model.Subject;
|
||||
import dev.ksan.etfoglasiserver.model.User;
|
||||
import dev.ksan.etfoglasiserver.repository.SubjectRepo;
|
||||
@@ -23,6 +24,7 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -61,8 +63,8 @@ public class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
public UserDTO updateUser(UserCreationDTO user) {
|
||||
Optional<User> existingUserOpt = userRepo.findByEmail(user.getEmail());
|
||||
public UserDTO updateUser(String email, UserCreationDTO user) {
|
||||
Optional<User> existingUserOpt = userRepo.findByEmail(email);
|
||||
|
||||
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
|
||||
throw new RuntimeException("Email taken");
|
||||
@@ -96,8 +98,12 @@ public class UserService {
|
||||
} else throw new RuntimeException("User not found");
|
||||
}
|
||||
|
||||
public void deleteUser(UUID userId) {
|
||||
userRepo.deleteById(userId);
|
||||
public void deleteUser(String email) {
|
||||
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
user.getSubjectSet().clear();
|
||||
userRepo.save(user);
|
||||
userRepo.delete(user);
|
||||
}
|
||||
|
||||
public UserDTO register(UserCreationDTO user) {
|
||||
@@ -144,19 +150,18 @@ public class UserService {
|
||||
|
||||
public JwtResponseDTO verify(UserLoginDTO user) {
|
||||
|
||||
Authentication authentication = authManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword())
|
||||
authManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
user.getEmail(),
|
||||
user.getPassword()
|
||||
)
|
||||
);
|
||||
|
||||
if (!authentication.isAuthenticated()) {
|
||||
throw new BadCredentialsException("Invalid username or password");
|
||||
}
|
||||
User foundUser = userRepo.findByEmail(user.getEmail())
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
String token = jwtService.generateToken(user.getEmail());
|
||||
|
||||
User foundUser = userRepo.findByEmail(user.getEmail())
|
||||
.orElseThrow(() -> new RuntimeException("User not found after authentication"));
|
||||
|
||||
return new JwtResponseDTO(
|
||||
token,
|
||||
foundUser.getEmail(),
|
||||
@@ -170,9 +175,9 @@ public class UserService {
|
||||
}
|
||||
|
||||
|
||||
public UserDTO addSubject(UUID userId, UUID subjectId) {
|
||||
public UserDTO addSubject(String email, UUID subjectId) {
|
||||
|
||||
User user = userRepo.findById(userId)
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
Subject subject = subjectRepo.findById(subjectId)
|
||||
@@ -185,9 +190,9 @@ public class UserService {
|
||||
return getUserDTO(user);
|
||||
}
|
||||
|
||||
public UserDTO removeSubject(UUID userId, UUID subjectId) {
|
||||
public UserDTO removeSubject(String email, UUID subjectId) {
|
||||
|
||||
User user = userRepo.findById(userId)
|
||||
User user = userRepo.findByEmail(email)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
Subject subject = subjectRepo.findById(subjectId)
|
||||
@@ -200,4 +205,11 @@ public class UserService {
|
||||
|
||||
return getUserDTO(user);
|
||||
}
|
||||
|
||||
public void updateNotificationType(String email, NotificationMethod notificationType) {
|
||||
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
user.setNotificationMethod(notificationType);
|
||||
userRepo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
spring.application.name=etfoglasi-server
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
|
||||
spring.datasource.username=test
|
||||
spring.datasource.password=test
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
spring.application.name=
|
||||
|
||||
spring.datasource.url=
|
||||
spring.datasource.username=
|
||||
spring.datasource.password=
|
||||
|
||||
# maybe use > openssl rand -base64 32
|
||||
jwt.secret=
|
||||
@@ -1,13 +0,0 @@
|
||||
package dev.ksan.etfoglasiserver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class EtfoglasiServerApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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=
|
||||
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "etfoglasi-frontend",
|
||||
"slug": "etfoglasi-frontend",
|
||||
"name": "etfoglasi",
|
||||
"slug": "etfoglasi",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "etfoglasifrontend",
|
||||
"scheme": "etfoglasi",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#1C1918",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "dev.ksan.etfoglasi"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
@@ -39,7 +41,9 @@
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"@react-native-firebase/app",
|
||||
"@react-native-firebase/messaging"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
@@ -47,4 +51,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,78 @@
|
||||
import "../globals.css";
|
||||
import { Stack } from "expo-router";
|
||||
import {AuthProvider} from "@/context/AuthContext";
|
||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import {
|
||||
getMessaging,
|
||||
onMessage,
|
||||
onNotificationOpenedApp,
|
||||
getInitialNotification,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import { registerDeviceToken, listenForTokenRefresh } from '@/services/notifications';
|
||||
import { AuthProvider, useAuth } from "@/context/AuthContext";
|
||||
import { useUpdatecheck } from '@/hooks/useUpdatecheck';
|
||||
import { UpdatePrompt } from '@/components/UpdatePrompt';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
// The FCM background handler now lives in index.js (registered before this
|
||||
// module loads) so it fires reliably while the app is backgrounded/killed.
|
||||
|
||||
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() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NotificationSetup />
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
<UpdateCheck />
|
||||
<Toast />
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 916 KiB |
@@ -0,0 +1,138 @@
|
||||
// components/UpdatePrompt.tsx
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Modal that appears when a newer build is available.
|
||||
// "Update now" opens the Gitea release page in the browser.
|
||||
// "Later" dismisses it for the current session (it reappears next launch).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { UpdateInfo } from '../hooks/useUpdatecheck';
|
||||
import { version as currentVersion } from '../version.json';
|
||||
|
||||
interface Props {
|
||||
updateInfo: UpdateInfo;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function UpdatePrompt({ updateInfo, onDismiss }: Props) {
|
||||
function openRelease() {
|
||||
Linking.openURL(updateInfo.releaseUrl).catch(() => {
|
||||
// If the device can't open the URL, just dismiss so the app isn't stuck.
|
||||
onDismiss();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onDismiss}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Update available</Text>
|
||||
|
||||
<Text style={styles.body}>
|
||||
A new version of the app is available.{'\n'}
|
||||
You are on build <Text style={styles.bold}>{currentVersion}</Text>,
|
||||
the latest is build{' '}
|
||||
<Text style={styles.bold}>{updateInfo.latestVersion}</Text>.
|
||||
</Text>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnSecondary]}
|
||||
onPress={onDismiss}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Remind me later"
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>Later</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnPrimary]}
|
||||
onPress={openRelease}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open the release page to update"
|
||||
>
|
||||
<Text style={styles.btnPrimaryText}>Update now</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#111',
|
||||
marginBottom: 12,
|
||||
},
|
||||
body: {
|
||||
fontSize: 15,
|
||||
color: '#444',
|
||||
lineHeight: 22,
|
||||
marginBottom: 24,
|
||||
},
|
||||
bold: {
|
||||
fontWeight: '700',
|
||||
color: '#111',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
btnPrimary: {
|
||||
backgroundColor: '#2563EB',
|
||||
},
|
||||
btnPrimaryText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
btnSecondary: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
btnSecondaryText: {
|
||||
color: '#374151',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const GITEA_URL = 'https://git.ksan.dev';
|
||||
|
||||
export const GITEA_OWNER = 'ksan';
|
||||
|
||||
export const GITEA_REPO = 'etf-oglasi';
|
||||
|
||||
|
||||
export const RELEASE_TAG_PREFIX = 'mobile-v';
|
||||
|
||||
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import Toast from "react-native-toast-message";
|
||||
import {
|
||||
authApi,
|
||||
|
||||
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
||||
LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
|
||||
} from "@/services/api";
|
||||
|
||||
type User = {
|
||||
@@ -11,6 +12,7 @@ type User = {
|
||||
email: string;
|
||||
token: string;
|
||||
subscribedSubjectIds: string[];
|
||||
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
@@ -23,6 +25,7 @@ type AuthContextValue = {
|
||||
unsubscribe: (subjectId: string) => Promise<void>;
|
||||
isSubscribed: (subjectId: string) => boolean;
|
||||
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
||||
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
@@ -43,17 +46,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
setUser(u);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
persist(null);
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: 'Session expired',
|
||||
text2: 'Please sign in again.',
|
||||
position: 'top',
|
||||
visibilityTime: 4000,
|
||||
});
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
|
||||
if (!user) return;
|
||||
await userApi.updateNotificationType(type, user.token);
|
||||
await persist({ ...user, notificationType: type });
|
||||
};
|
||||
|
||||
const login = async (payload: LoginPayload) => {
|
||||
const res = await authApi.login(payload);
|
||||
|
||||
// Gracefully fetch subscriptions — don't block login if this fails
|
||||
let subscribedSubjectIds: string[] = [];
|
||||
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
|
||||
|
||||
if (res.userId) {
|
||||
try {
|
||||
const dto = await authApi.getUser(res.userId, res.token);
|
||||
const dto = await authApi.getUser(res.token);
|
||||
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
||||
} catch {
|
||||
// aaaa
|
||||
notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
|
||||
} catch (e) {
|
||||
console.error('Failed to load subscriptions on login:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +86,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
email: res.email,
|
||||
token: res.token,
|
||||
subscribedSubjectIds,
|
||||
notificationType,
|
||||
});
|
||||
};
|
||||
|
||||
const register = async (payload: RegisterPayload) => {
|
||||
await authApi.register(payload);
|
||||
await login({ email: payload.email, password: payload.password });
|
||||
await login({
|
||||
email: payload.email.trim(),
|
||||
password: payload.password.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
||||
@@ -91,7 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const subscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token);
|
||||
const dto = await subscriptionsApi.subscribe( subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
||||
@@ -101,7 +129,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const unsubscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token);
|
||||
const dto = await subscriptionsApi.unsubscribe( subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
||||
@@ -113,7 +141,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser, updateNotificationType }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Custom entry point. The FCM background handler must be registered in the very
|
||||
// first module that loads — before the router/app mounts — so it reliably fires
|
||||
// when the app is backgrounded or fully quit. Registering it here (rather than in
|
||||
// app/_layout.tsx) is what react-native-firebase requires for the killed state.
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||
|
||||
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
|
||||
const title = remoteMessage.data?.title;
|
||||
const body = remoteMessage.data?.body;
|
||||
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' } },
|
||||
});
|
||||
});
|
||||
|
||||
// Hand off to expo-router, which mounts app/_layout.tsx and the routes.
|
||||
import 'expo-router/entry';
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import React from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||
import AllFeed from "@/screens/AllFeed";
|
||||
import Profile from "@/screens/Profile";
|
||||
import {enableScreens} from "react-native-screens";
|
||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||
import AuthGate from "@/screens/AuthGate";
|
||||
import {AuthProvider, useAuth} from "@/context/AuthContext";
|
||||
|
||||
|
||||
// Must be called before any navigator renders
|
||||
enableScreens();
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
const LIGHT_TAB = {
|
||||
bg: "#FFFFFF",
|
||||
border: "#E8E2D5",
|
||||
active: "#C4622D",
|
||||
inactive: "#8A8278",
|
||||
label: "#1A1714",
|
||||
};
|
||||
const DARK_TAB = {
|
||||
bg: "#161513",
|
||||
border: "#2C2A27",
|
||||
active: "#E07B45",
|
||||
inactive: "#706D67",
|
||||
label: "#F0EDE8",
|
||||
};
|
||||
|
||||
|
||||
function ProfileTab() {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return user ? <Profile /> : <AuthGate />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const scheme = useColorScheme();
|
||||
const dark = scheme === "dark";
|
||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||
|
||||
const navTheme = dark
|
||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer theme={navTheme}>
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
|
||||
tabBarStyle: {
|
||||
backgroundColor: tab.bg,
|
||||
borderTopColor: tab.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 6,
|
||||
},
|
||||
|
||||
tabBarActiveTintColor: tab.active,
|
||||
tabBarInactiveTintColor: tab.inactive,
|
||||
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
const icons: Record<
|
||||
string,
|
||||
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
||||
> = {
|
||||
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
||||
"Discover": { active: "compass", inactive: "compass-outline" },
|
||||
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
||||
};
|
||||
const set = icons[route.name];
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
name={focused ? set.active : set.inactive}
|
||||
size={focused ? size + 1 : size}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Subscribed"
|
||||
component={SubscribedFeed}
|
||||
options={{ title: "My Feed" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Discover"
|
||||
component={AllFeed}
|
||||
options={{ title: "Discover" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileTab}
|
||||
options={{ title: "Profile" }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaProvider>
|
||||
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "etfoglasi-frontend",
|
||||
"main": "expo-router/entry",
|
||||
"name": "etfoglasi",
|
||||
"main": "index.js",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@notifee/react-native": "^9.1.8",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-firebase/app": "^24.0.0",
|
||||
"@react-native-firebase/messaging": "^24.0.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
@@ -37,6 +40,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-tailwindcss": "^1.1.11",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwindcss": "^3.4.18"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, {useCallback, useState} from "react";
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import CollapsibleCategory from "../components/CollapsibleCategory";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
|
||||
const GROUPS = [
|
||||
@@ -40,6 +41,12 @@ export default function AllFeed() {
|
||||
setTimeout(() => setRefreshing(false), 800);
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
handleRefresh();
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
|
||||
@@ -25,13 +25,19 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const submit = async () => {
|
||||
|
||||
const trimmedEmail = email.trim();
|
||||
const trimmedPassword = password.trim();
|
||||
|
||||
|
||||
|
||||
setError("");
|
||||
if (!email || !password) { setError("Please fill in all fields."); return; }
|
||||
if (!trimmedEmail || !trimmedPassword) { setError("Please fill in all fields."); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "login") await login({ email, password });
|
||||
else await register({ email, password, name });
|
||||
if (mode === "login") await login({ email:trimmedEmail, password:trimmedPassword});
|
||||
else await register({ email: trimmedEmail,password:trimmedPassword , name });
|
||||
onSuccess?.();
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Something went wrong.");
|
||||
|
||||
@@ -23,6 +23,8 @@ type SettingRowProps = {
|
||||
accent?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
||||
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
||||
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
const [showSubs, setShowSubs] = useState(false);
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
const { user, updateNotificationType } = useAuth();
|
||||
const [notifications, setNotifications] = useState(
|
||||
user?.notificationType === 'PUSH_NOTIFICATION'
|
||||
);
|
||||
const [digest, setDigest] = React.useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const handleNotificationToggle = async (value: boolean) => {
|
||||
setNotifications(value);
|
||||
try {
|
||||
await updateNotificationType(
|
||||
value ? 'PUSH_NOTIFICATION' : 'NO_NOTIFICATION'
|
||||
);
|
||||
} catch {
|
||||
setNotifications(!value); // revert on failure
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
>
|
||||
<SectionLabel title="Notifications" />
|
||||
<SettingRow
|
||||
icon="notifications-outline" label="Push notifications (TODO)"
|
||||
toggle toggleValue={notifications} onToggle={setNotifications}
|
||||
icon="notifications-outline"
|
||||
label="Push notifications"
|
||||
toggle
|
||||
toggleValue={notifications}
|
||||
onToggle={handleNotificationToggle}
|
||||
accent={dark ? "#E07B45" : "#C4622D"}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="mail-outline" label="Daily digest email (TODO)"
|
||||
toggle toggleValue={digest} onToggle={setDigest}
|
||||
icon="mail-outline"
|
||||
label="Weekly Digest email (TODO)"
|
||||
toggle
|
||||
toggleValue={digest}
|
||||
onToggle={setDigest}
|
||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||
/>
|
||||
|
||||
@@ -231,32 +253,21 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
||||
<EditProfile onClose={() => setShowEdit(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
||||
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
visible={showSubs}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowSubs(false)}
|
||||
>
|
||||
<Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
|
||||
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default function Profile() {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
|
||||
import ExpandableItem from "../components/ExpandableItem";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Entry, entriesApi } from "../services/api";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
type SortMode = "time" | "subject";
|
||||
|
||||
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.subscribedSubjectIds]);
|
||||
}, [user?.subscribedSubjectIds?.join(',')]);
|
||||
|
||||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||||
// Always re-fetch when tab gains focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchFeed();
|
||||
}, [fetchFeed])
|
||||
);
|
||||
|
||||
// Filter then group
|
||||
const filtered = useMemo(() => {
|
||||
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
|
||||
<View className="items-center mt-16 px-8">
|
||||
<Text className="text-4xl mb-3">🔍</Text>
|
||||
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
No results for "{query}"
|
||||
No results for "{query}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Used by CI to stamp a mobile release build with its version number.
|
||||
// Updates version.json (read by hooks/useUpdatecheck.tsx) and app.json's
|
||||
// android.versionCode/versionName so the build, the in-app update check,
|
||||
// and the Gitea release tag (mobile-vN) all agree on the same number.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const version = parseInt(process.argv[2], 10);
|
||||
if (!Number.isInteger(version) || version <= 0) {
|
||||
console.error('Usage: node set-mobile-version.js <positive integer>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'version.json'),
|
||||
JSON.stringify({ version }, null, 2) + '\n',
|
||||
);
|
||||
|
||||
const appJsonPath = path.join(root, 'app.json');
|
||||
const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8'));
|
||||
appJson.expo.version = `1.0.${version}`;
|
||||
appJson.expo.android = appJson.expo.android ?? {};
|
||||
appJson.expo.android.versionCode = version;
|
||||
fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n');
|
||||
|
||||
console.log(`Stamped mobile version ${version}`);
|
||||
@@ -1,11 +1,19 @@
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api';
|
||||
|
||||
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
|
||||
function authHeader(token?: string) {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
// Called once from AuthProvider so api.tsx can trigger a logout/relogin
|
||||
// when the backend rejects a request due to a missing/expired/invalid token.
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
@@ -25,7 +33,14 @@ async function request<T>(
|
||||
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => `HTTP ${res.status}`);
|
||||
console.log('Failed URL:', res.url);
|
||||
console.log('Status:', res.status);
|
||||
console.log('Response:', errorText);
|
||||
if (res.status === 401 && token) onUnauthorized?.();
|
||||
throw new Error(errorText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -65,6 +80,7 @@ export type UserDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
subjectSet: Subject[];
|
||||
notificationType: NotificationType;
|
||||
|
||||
};
|
||||
|
||||
@@ -103,30 +119,46 @@ export type RegisterPayload = { email: string; password: string; name?: string }
|
||||
export const authApi = {
|
||||
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
|
||||
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body),
|
||||
|
||||
getUser: (token: string) =>
|
||||
get<UserDTO>("/users/me", token),
|
||||
|
||||
updateUser: (body: UserUpdateDTO, token: string) =>
|
||||
request<UserDTO>("PUT", "/users/me", token, body),
|
||||
|
||||
deleteUser: (token: string) =>
|
||||
del<void>("/users/me", token),
|
||||
};
|
||||
|
||||
|
||||
export const userApi = {
|
||||
updateNotificationType: (
|
||||
type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION',
|
||||
token: string
|
||||
) => post<void>('/users/me/notification-type', { notificationType: type }, token),
|
||||
};
|
||||
|
||||
export const subjectsApi = {
|
||||
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
||||
};
|
||||
|
||||
export const subscriptionsApi = {
|
||||
subscribe: (userId: string, subjectId: string, token: string) =>
|
||||
subscribe: (subjectId: string, token: string) =>
|
||||
put<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
{}, // body placeholder
|
||||
`/users/me/subjects/${subjectId}`,
|
||||
{},
|
||||
token
|
||||
),
|
||||
|
||||
unsubscribe: (userId: string, subjectId: string, token: string) =>
|
||||
unsubscribe: (subjectId: string, token: string) =>
|
||||
del<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
`/users/me/subjects/${subjectId}`,
|
||||
token
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const entriesApi = {
|
||||
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||
get<SpringPage<Entry>>("/entries", undefined, params),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
getMessaging,
|
||||
requestPermission,
|
||||
getToken,
|
||||
onTokenRefresh,
|
||||
AuthorizationStatus,
|
||||
} from '@react-native-firebase/messaging';
|
||||
import notifee, { AndroidImportance } from '@notifee/react-native';
|
||||
import { post } from '@/services/api';
|
||||
|
||||
export async function registerDeviceToken(authToken: string): Promise<void> {
|
||||
const messaging = getMessaging();
|
||||
const status = await requestPermission(messaging);
|
||||
const granted =
|
||||
status === AuthorizationStatus.AUTHORIZED ||
|
||||
status === AuthorizationStatus.PROVISIONAL;
|
||||
|
||||
if (!granted) return;
|
||||
|
||||
const fcmToken = await getToken(messaging);
|
||||
|
||||
await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/device-tokens/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ token: fcmToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listenForTokenRefresh(authToken: string): () => void {
|
||||
return onTokenRefresh(getMessaging(), async (newToken) => {
|
||||
await post('/device-tokens/register', { token: newToken }, authToken);
|
||||
});
|
||||
}
|
||||
|
||||
export async function displayLocalNotification(title?: string, body?: string) {
|
||||
if (!title && !body) return;
|
||||
|
||||
const channelId = await notifee.createChannel({
|
||||
id: 'default',
|
||||
name: 'General',
|
||||
importance: AndroidImportance.HIGH,
|
||||
});
|
||||
|
||||
await notifee.displayNotification({
|
||||
title,
|
||||
body,
|
||||
android: {
|
||||
channelId,
|
||||
pressAction: { id: 'default' },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||