39 Commits

Author SHA1 Message Date
ksan 45e57181a9 fix push notification delivery on android
CI/CD / Backend Unit Tests (push) Successful in 2m16s
CI/CD / Deploy (push) Successful in 2m6s
CI/CD / Mobile Release (push) Successful in 22m0s
2026-07-30 15:28:12 +02:00
ksan ce01ab763c added app icon and readme
CI/CD / Backend Unit Tests (push) Successful in 3m16s
CI/CD / Deploy (push) Successful in 2m45s
CI/CD / Mobile Release (push) Has been cancelled
2026-07-30 15:12:17 +02:00
ksan dcf65840c0 fixing wrong directory in ci
CI/CD / Backend Unit Tests (push) Successful in 2m30s
CI/CD / Deploy (push) Successful in 2m7s
CI/CD / Mobile Release (push) Successful in 21m43s
2026-06-11 17:54:12 +02:00
ksan 194d598d6d fixing spelling mistake
CI/CD / Backend Unit Tests (push) Successful in 2m13s
CI/CD / Deploy (push) Successful in 2m16s
CI/CD / Mobile Release (push) Failing after 21m26s
2026-06-11 17:23:21 +02:00
ksan d76434014f aaa
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m7s
CI/CD / Mobile Release (push) Failing after 21m35s
2026-06-11 16:51:29 +02:00
ksan 266802088a fixing
CI/CD / Backend Unit Tests (push) Successful in 2m8s
CI/CD / Deploy (push) Successful in 2m9s
CI/CD / Mobile Release (push) Has been cancelled
2026-06-11 16:43:38 +02:00
ksan 6c98e4a469 updated ci file
CI/CD / Backend Unit Tests (push) Successful in 2m12s
CI/CD / Deploy (push) Successful in 2m6s
CI/CD / Mobile Release (push) Failing after 1m12s
2026-06-11 16:33:16 +02:00
ksan 5d2898dd13 fixing error
CI/CD / Backend Unit Tests (push) Successful in 2m20s
CI/CD / Deploy (push) Successful in 2m8s
CI/CD / Mobile Release (push) Failing after 25m30s
2026-06-11 15:47:48 +02:00
ksan 694b68cbc5 updated ci/cd to add releases and testing claude code on my project
CI/CD / Backend Unit Tests (push) Successful in 2m11s
CI/CD / Deploy (push) Successful in 2m0s
CI/CD / Mobile Release (push) Failing after 21m39s
2026-06-11 15:11:09 +02:00
ksan 0aa633a483 added update check
CI/CD / Backend Unit Tests (push) Successful in 2m29s
CI/CD / Deploy (push) Successful in 2m16s
2026-06-11 14:22:08 +02:00
ksan 8404bcafd0 removed test that was not needed
CI/CD / Backend Unit Tests (push) Successful in 2m14s
CI/CD / Deploy (push) Successful in 2m10s
2026-06-11 14:01:16 +02:00
ksan e3529f892c final touches
CI/CD / Deploy (push) Has been cancelled
CI/CD / Backend Unit Tests (push) Has been cancelled
2026-06-11 13:54:10 +02:00
ksan 7d7e641f5b fixed push notifications and auth modified
CI/CD / Backend Unit Tests (push) Successful in 2m17s
CI/CD / Deploy (push) Successful in 2m26s
2026-06-11 13:30:36 +02:00
ksan 3afe5e2931 switching branch 2026-06-11 11:54:14 +02:00
ksan 14725e180a push notifications not working for some reason
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Successful in 1m38s
2026-06-11 01:29:48 +02:00
ksan 30d2abd4c5 fixing cors issue and ci/cd
CI/CD / Backend Unit Tests (push) Successful in 1m52s
CI/CD / Deploy (push) Successful in 1m46s
2026-06-10 22:54:00 +02:00
ksan ee4eff34c9 testing if this will fix this
CI/CD / Backend Unit Tests (push) Successful in 1m44s
CI/CD / Deploy (push) Successful in 1m46s
2026-06-10 19:42:31 +02:00
ksan 62a3b11f43 moved from echo to printf
CI/CD / Backend Unit Tests (push) Successful in 1m46s
CI/CD / Deploy (push) Successful in 1m50s
2026-06-10 19:30:10 +02:00
ksan 6f941e7842 fixing missing creds
CI/CD / Backend Unit Tests (push) Successful in 2m3s
CI/CD / Deploy (push) Failing after 1m49s
2026-06-10 19:22:10 +02:00
ksan 8db2ecfefe removed excess
CI/CD / Backend Unit Tests (push) Successful in 1m45s
CI/CD / Deploy (push) Successful in 1m56s
2026-06-10 18:41:54 +02:00
ksan 4621c0d5ca fixing issue with firebase on deploy 2026-06-10 18:41:54 +02:00
ksan 35c40791e6 Merge pull request 'Dev' (#1) from dev into main
CI/CD / Backend Unit Tests (push) Successful in 1m43s
CI/CD / Deploy (push) Successful in 1m59s
Reviewed-on: #1
testiranje
2026-06-10 16:12:21 +00:00
ksan b53805ee3f testing new ci/cd
CI/CD / Backend Unit Tests (push) Successful in 1m48s
CI/CD / Deploy (push) Has been skipped
CI/CD / Backend Unit Tests (pull_request) Successful in 1m45s
CI/CD / Deploy (pull_request) Has been skipped
2026-06-10 18:03:56 +02:00
ksan 28bd6a8cbc fixed jwtservicetest not to enclude entire spring boot test
CI / backend-tests (push) Successful in 2m10s
2026-06-10 14:56:19 +02:00
ksan d598fdca1a removed cache
CI / backend-tests (push) Failing after 2m28s
2026-06-10 14:44:15 +02:00
ksan 09b6193077 added some tests and fixed server secret to be persistent
CI / backend-tests (push) Has been cancelled
2026-06-10 14:36:38 +02:00
ksan 61f9404a8a Temporarily remove failing integration tests
CI / test (push) Successful in 1m49s
2026-06-04 00:09:58 +02:00
ksan f53422fa8c added test application.properties
CI / test (push) Failing after 1m46s
2026-06-04 00:03:07 +02:00
ksan 86664921ae idk
CI / test (push) Failing after 2m2s
2026-06-03 23:55:37 +02:00
ksan 04f7375651 i think this will fix it? needed db for integretion testing
CI / test (push) Failing after 1m57s
2026-06-03 23:48:56 +02:00
ksan 629ae4c754 put the correct java version. i think?
CI / test (push) Failing after 2m8s
2026-06-03 23:39:35 +02:00
ksan 9bb40249ea forgot to add tests, and removed gradle cache
CI / test (push) Failing after 1m18s
2026-06-03 23:34:22 +02:00
ksan 4e5dee2e45 added some tests? and CI
CI / test (push) Failing after 11m59s
2026-06-03 23:25:08 +02:00
ksan 6559a9cd4b added push notifications 2026-06-03 19:13:56 +02:00
ksan a9278b8269 i think i added push notifications to backend but im going to sleep now and ill finish frontend later 2026-06-02 00:47:47 +02:00
ksan 4db77055ed commit before i break things 2026-06-01 23:40:58 +02:00
ksan 73c9bc1f14 updated readme 2026-05-31 21:21:13 +02:00
ksan 7d4ffee203 Remove bin directory from repo 2026-05-31 21:10:18 +02:00
ksan 6e8cb8d560 Remove bin from repository 2026-05-31 21:08:25 +02:00
98 changed files with 3452 additions and 915 deletions
+198
View File
@@ -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
+65 -58
View File
@@ -1,18 +1,8 @@
backend/.db/ ########################
backend/.env # GLOBAL (all projects)
backend/todo ########################
backend/.creds
backend/init/*
backend/*temp.java
backend/src/main/java/dev/ksan/etfoglasiserver/temp.java
todo
# Java / Gradle
backend/.gradle/
backend/build/
# IDEs # IDEs
.idea/ .idea/
@@ -21,7 +11,33 @@ backend/build/
*.ipr *.ipr
*.iws *.iws
# Eclipse / STS # OS
.DS_Store
# Logs
*.log
########################
# BACKEND (Spring Boot)
########################
backend/.gradle/
backend/build/
backend/bin/
backend/src/main/resources/application.properties
backend/.env
backend/.creds
backend/.db/
backend/init/*
backend/**/*temp.java
backend/src/main/resources/google-services.json
backend/src/main/resources/etf-oglasi-firebase.json
backend/src/main/resources/firebase.json
# Java / Eclipse / STS
.classpath .classpath
.project .project
.settings/ .settings/
@@ -34,54 +50,45 @@ nbbuild/
nbdist/ nbdist/
.nb-gradle/ .nb-gradle/
# Environment & secrets ########################
backend/.env # FRONTEND (React Native / Expo)
backend/.creds ########################
# Local database frontend/node_modules/
backend/.db/
## frontend frontend/google-services.json
# dependencies frontend/.expo/
node_modules/ frontend/dist/
frontend/web-build/
frontend/.kotlin/
frontend/*.orig.*
frontend/*.jks
frontend/*.p8
frontend/*.p12
frontend/*.key
frontend/*.mobileprovision
frontend/.metro-health-check*
frontend/npm-debug.*
frontend/yarn-debug.*
frontend/yarn-error.*
frontend/.env
frontend/.env*.local
frontend/*.tsbuildinfo
# Native builds
frontend/ios/
frontend/android/
# Expo # Expo
.expo/ frontend/expo-env.d.ts
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug CLAUDE.md
npm-debug.* ksan.dev.keystore
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
frontend/.env
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android
+47
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
-4
View File
@@ -1,4 +0,0 @@
spring.application.name=etfoglasi-server
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
spring.datasource.username=test
spring.datasource.password=test
+17
View File
@@ -39,6 +39,19 @@ dependencies {
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
implementation("io.jsonwebtoken:jjwt-api:0.13.0") implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
implementation("org.projectlombok:lombok:1.18.42")
annotationProcessor 'org.projectlombok:lombok:1.18.42'
implementation 'com.google.firebase:firebase-admin:9.9.0'
testImplementation 'org.springframework.security:spring-security-test'
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation("org.testcontainers:postgresql:1.21.3")
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
testImplementation("org.mockito:mockito-inline:5.2.0")
testImplementation 'org.mockito:mockito-core'
} }
generateJava { generateJava {
@@ -49,4 +62,8 @@ generateJava {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
jvmArgs += [
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('byte-buddy-agent') }}"
]
} }
+4 -4
View File
@@ -3,11 +3,11 @@ services:
image: 'postgres:16' image: 'postgres:16'
container_name: etfo-db container_name: etfo-db
environment: environment:
- 'POSTGRES_DB=etfo-db' - POSTGRES_DB=etfo-db
- 'POSTGRES_PASSWORD=test' - POSTGRES_PASSWORD=test
- 'POSTGRES_USER=test' - POSTGRES_USER=test
volumes: volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql - ./init.sql:/docker-entrypoint-initdb.d/init.sql
- ./.db:/var/lib/postgresql/data - ./.db:/var/lib/postgresql/data
ports: ports:
- '5432:5432' - 5432:5432
+28 -2
View File
@@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS public.subjects
CONSTRAINT subjects_pkey PRIMARY KEY (id) CONSTRAINT subjects_pkey PRIMARY KEY (id)
); );
CREATE TABLE IF NOT EXISTS public."user-subject" CREATE TABLE IF NOT EXISTS public."user_subject"
( (
user_id uuid NOT NULL, user_id uuid NOT NULL,
subject_id uuid NOT NULL, subject_id uuid NOT NULL,
@@ -50,11 +50,37 @@ CREATE TABLE IF NOT EXISTS public.users
email character varying(255) COLLATE pg_catalog."default" NOT NULL, email character varying(255) COLLATE pg_catalog."default" NOT NULL,
password 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, 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 users_pkey PRIMARY KEY (id),
CONSTRAINT unique_email UNIQUE (email) CONSTRAINT unique_email UNIQUE (email)
); );
CREATE TABLE IF NOT EXISTS public.device_tokens
(
id uuid NOT NULL DEFAULT uuid_generate_v4(),
user_id uuid NOT NULL,
fcm_token text NOT NULL,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT device_tokens_pkey PRIMARY KEY (id),
CONSTRAINT fk_device_tokens_user
FOREIGN KEY (user_id)
REFERENCES public.users(id)
ON DELETE CASCADE,
CONSTRAINT uq_device_tokens_fcm_token
UNIQUE (fcm_token)
);
CREATE INDEX idx_device_tokens_user_id
ON public.device_tokens(user_id);
ALTER TABLE IF EXISTS public.alt_subject ALTER TABLE IF EXISTS public.alt_subject
ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id) ADD CONSTRAINT alt_subject_subject_id_fkey FOREIGN KEY (subject_id)
REFERENCES public.subjects (id) MATCH SIMPLE REFERENCES public.subjects (id) MATCH SIMPLE
@@ -11,7 +11,9 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync
@SpringBootApplication @SpringBootApplication
public class EtfoglasiServerApplication { public class EtfoglasiServerApplication {
@@ -0,0 +1,42 @@
package dev.ksan.etfoglasiserver.config;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;
@Configuration
public class FirebaseConfig {
@Value("${firebase.credentials.path}")
private String credentialsPath;
@Bean
public FirebaseApp firebaseApp() throws IOException {
GoogleCredentials credentials = GoogleCredentials
.fromStream(new FileInputStream(credentialsPath))
.createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging"));
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(credentials)
.build();
if (FirebaseApp.getApps().isEmpty()) {
return FirebaseApp.initializeApp(options);
}
return FirebaseApp.getInstance();
}
@Bean
public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) {
return FirebaseMessaging.getInstance(firebaseApp);
}
}
@@ -2,7 +2,7 @@ package dev.ksan.etfoglasiserver.config;
import dev.ksan.etfoglasiserver.service.JWTService; import dev.ksan.etfoglasiserver.service.JWTService;
import dev.ksan.etfoglasiserver.service.MyUserDetailsService; import dev.ksan.etfoglasiserver.service.MyUserDetailsService;
import dev.ksan.etfoglasiserver.service.UserService; import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -33,9 +33,15 @@ public class JwtFilter extends OncePerRequestFilter {
String token = null; String token = null;
String username = null; String username = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) { if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7); token = authHeader.substring(7);
username = jwtService.extractEmail(token); 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) { if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
@@ -3,6 +3,7 @@ package dev.ksan.etfoglasiserver.config;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
@@ -13,8 +14,14 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -29,19 +36,39 @@ public class SecurityConfig {
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(customizer -> customizer.disable()). return http
authorizeHttpRequests(request -> request .csrf(customizer -> customizer.disable())
.cors(cors -> {})
.authorizeHttpRequests(request -> request
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/login", "/api/register").permitAll() .requestMatchers("/api/login", "/api/register").permitAll()
.requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll() .requestMatchers("/api/subjects/**", "/api/entries", "/api/groups").permitAll()
.anyRequest().authenticated()). .anyRequest().authenticated()
httpBasic(Customizer.withDefaults()). )
sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build(); .build();
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:8081",
"https://etf-oglasi.ksan.dev"
));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@@ -61,4 +88,10 @@ public class SecurityConfig {
} }
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
} }
@@ -0,0 +1,30 @@
package dev.ksan.etfoglasiserver.controller;
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
import dev.ksan.etfoglasiserver.service.DeviceTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/device-tokens")
@RequiredArgsConstructor
public class DeviceTokenController {
private final DeviceTokenService deviceTokenService;
@PostMapping("/register")
public ResponseEntity<Void> register(
@RequestBody RegisterTokenRequest request,
@AuthenticationPrincipal UserDetails principal) {
deviceTokenService.registerToken(
principal.getUsername(),
request.getToken());
return ResponseEntity.noContent().build();
}
}
@@ -11,7 +11,9 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@@ -23,7 +25,7 @@ public class EntryController {
return service.getEntryById(entryId); return service.getEntryById(entryId);
} }
//ignore this for now // TODO only for testing
@PostMapping("/entries") @PostMapping("/entries")
public void addEntry(@RequestBody EntryDTO entry) { public void addEntry(@RequestBody EntryDTO entry) {
service.addEntry(entry); service.addEntry(entry);
@@ -36,23 +38,38 @@ public class EntryController {
} }
@GetMapping("/entries") @GetMapping("/entries")
public Page<Entry> getEntries( public Page<Entry> getEntries(
@RequestParam(required = false) String subjectId, @RequestParam(required = false) String subjectId,
@RequestParam(required = false) String groupName, @RequestParam(required = false) String groupName,
@RequestParam(defaultValue = "0") int page @RequestParam(defaultValue = "0") int page
) { ) {
UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null; // UUID parsedSubjectId = subjectId != null ? UUID.fromString(subjectId) : null;
UUID parsedSubjectId = null;
if (subjectId != null) {
try{
parsedSubjectId = UUID.fromString(subjectId);
}catch (IllegalArgumentException e){
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid subject ID");
}
}
return service.getEntries(parsedSubjectId, groupName, page); return service.getEntries(parsedSubjectId, groupName, page);
} }
@PutMapping("/entries")
public void updateEntry(@RequestBody EntryDTO entry) {
service.updateEntry(entry);
}
@DeleteMapping("/entries/{entryId}") // @PutMapping("/entries")
public void deleteEntry(@PathVariable String entryId) { // public void updateEntry(@RequestBody EntryDTO entry) {
service.deleteEntry(entryId); // service.updateEntry(entry);
} // }
// @DeleteMapping("/entries/{entryId}")
// public void deleteEntry(@PathVariable String entryId) {
// service.deleteEntry(entryId);
// }
//
} }
@@ -23,13 +23,13 @@ public class SubjectAltController {
return service.getSubjectAlt(subjectId); return service.getSubjectAlt(subjectId);
} }
@PostMapping("/subjectalt") // @PostMapping("/subjectalt")
public void addSubject(@RequestBody SubjectAlt subject) { // public void addSubject(@RequestBody SubjectAlt subject) {
service.addSubjectAlt(subject); // service.addSubjectAlt(subject);
} // }
//
@DeleteMapping("/subjectalt/{subjectId}") // @DeleteMapping("/subjectalt/{subjectId}")
public void deleteSubject(@PathVariable UUID subjectId) { // public void deleteSubject(@PathVariable UUID subjectId) {
service.deleteSubjectAlt(subjectId); // service.deleteSubjectAlt(subjectId);
} // }
} }
@@ -22,18 +22,21 @@ public class SubjectController {
return service.getSubject(subjectId); return service.getSubject(subjectId);
} }
@PostMapping("/subjects") @PostMapping("/subjects")
public void addSubject(@RequestBody Subject subject) { public void addSubject(@RequestBody Subject subject) {
service.addSubject(subject); service.addSubject(subject);
} }
@PutMapping("/subjects") //TODO uncomment this if ever roles are added so only admin should modify this (didnt really feel like doing it rn)
public void updateSubject(@RequestBody Subject subject) { //
service.updateSubject(subject); // @PutMapping("/subjects")
} // public void updateSubject(@RequestBody Subject subject) {
// service.updateSubject(subject);
// }
@DeleteMapping("/subjects/{subjectId}") // @DeleteMapping("/subjects/{subjectId}")
public void deleteSubject(@PathVariable UUID subjectId) { // public void deleteSubject(@PathVariable UUID subjectId) {
service.deleteSubject(subjectId); // service.deleteSubject(subjectId);
} // }
} }
@@ -5,13 +5,17 @@ import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import dev.ksan.etfoglasiserver.dto.UserDTO; import dev.ksan.etfoglasiserver.dto.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.service.UserService; import dev.ksan.etfoglasiserver.service.UserService;
import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
@@ -20,76 +24,106 @@ public class UserController {
@Autowired UserService service; @Autowired UserService service;
@GetMapping("users/{userId}") @GetMapping("/users/me")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID userId) { public ResponseEntity<UserDTO> getUserById(Authentication authentication) {
String email = authentication.getName();
UserDTO user = service.getUserById(userId); User user = service.getAccountByEmail(email)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "User not found"
));
if (user != null) { return ResponseEntity.ok(new UserDTO(user));
return new ResponseEntity<>(user, HttpStatus.OK);
}
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
} }
@PutMapping("/users") @PutMapping("/users/me")
public ResponseEntity<UserDTO> updateUser(@RequestBody UserCreationDTO user) { public ResponseEntity<UserDTO> updateUser(
@RequestBody UserCreationDTO user,
Authentication authentication) {
UserDTO userUpdated = service.updateUser(user); String email = authentication.getName();
if (userUpdated != null) {
return new ResponseEntity<>(userUpdated, HttpStatus.OK); UserDTO userUpdated = service.updateUser(email, user);
}
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return ResponseEntity.ok(userUpdated);
} }
@PutMapping("/users/{userId}/subjects/{subjectId}")
public ResponseEntity<UserDTO> addSubject(
@PathVariable UUID userId,
@PathVariable UUID subjectId
) {
UserDTO updatedUser = service.addSubject(userId, subjectId);
return ResponseEntity.ok(updatedUser); @PostMapping("/users/me/notification-type")
} public ResponseEntity<?> updateNotificationType(
@RequestBody Map<String, String> request,
Authentication authentication) {
@DeleteMapping("/users/{userId}/subjects/{subjectId}") String email = authentication.getName();
public ResponseEntity<UserDTO> removeSubject( NotificationMethod type = NotificationMethod.valueOf(request.get("notificationType"));
@PathVariable UUID userId, service.updateNotificationType(email, type);
@PathVariable UUID subjectId
) {
UserDTO updatedUser = service.removeSubject(userId, subjectId); return ResponseEntity.ok(Map.of("status", "updated"));
}
return ResponseEntity.ok(updatedUser); @PutMapping("/users/me/subjects/{subjectId}")
} public ResponseEntity<UserDTO> addSubject(
@PathVariable UUID subjectId,
Authentication authentication) {
String email = authentication.getName();
UserDTO updatedUser = service.addSubject(email, subjectId);
return ResponseEntity.ok(updatedUser);
}
@DeleteMapping("/users/me/subjects/{subjectId}")
public ResponseEntity<UserDTO> removeSubject(
@PathVariable UUID subjectId,
Authentication authentication) {
String email = authentication.getName();
UserDTO updatedUser = service.removeSubject(email, subjectId);
return ResponseEntity.ok(updatedUser);
}
// not the best, should fix this later
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) { public ResponseEntity<JwtResponseDTO> login(@RequestBody UserLoginDTO user) {
JwtResponseDTO userVer = service.verify(user); try {
JwtResponseDTO userVer = service.verify(user);
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
}catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
if (userVer != null) return new ResponseEntity<>(userVer, HttpStatus.OK);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} }
// not the best, should fix this later too
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) { public ResponseEntity<UserDTO> register(@RequestBody UserCreationDTO user) {
UserDTO newUser = service.register(user); try {
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED); UserDTO newUser = service.register(user);
if (newUser != null) return new ResponseEntity<>(newUser, HttpStatus.CREATED);
}catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
return new ResponseEntity<>(HttpStatus.BAD_REQUEST); return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
} }
//
// @DeleteMapping("/users/{userId}")
// public void deleteUser(@PathVariable UUID userId) {
// service.deleteUser(userId);
// }
/*
@PostMapping("/users")
public void addUser(@RequestBody UserCreationDTO user) { @DeleteMapping("/users/me")
NotificationMethod method = NotificationMethod.valueOf(user.getNotification()); public ResponseEntity<Void> deleteUser(Authentication authentication) {
user.setNotificationMethod(method);
service.addUser(user); String email = authentication.getName();
service.deleteUser(email);
return ResponseEntity.noContent().build();
} }
*/
} }
@@ -12,6 +12,11 @@ public class UserCreationDTO {
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
private String notification; private String notification;
public UserCreationDTO(String mail, String password) {
this.email = mail;
this.password = password;
}
public String getEmail() { public String getEmail() {
return email; return email;
} }
@@ -2,13 +2,26 @@ package dev.ksan.etfoglasiserver.dto;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
import dev.ksan.etfoglasiserver.model.User;
import lombok.Getter;
import lombok.Setter;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
public class UserDTO { public class UserDTO {
@Setter
@Getter
private UUID id; private UUID id;
@Setter
@Getter
private String email; private String email;
@Setter
@Getter
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
@Getter
@Setter
private NotificationMethod notificationType;
public UserDTO() {} public UserDTO() {}
@@ -17,31 +30,17 @@ public class UserDTO {
this.id = id; this.id = id;
this.email = email; this.email = email;
this.subjectSet = subjectSet; this.subjectSet = subjectSet;
this.notificationType = notificationMethod2;
} }
public UUID getId() { public UserDTO(User user) {
return id; this.id = user.getId();
} this.email = user.getEmail();
this.subjectSet = user.getSubjectSet();
this.notificationType = user.getNotificationMethod();
public void setId(UUID id) { }
this.id = id;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Set<Subject> getSubjectSet() {
return subjectSet;
}
public void setSubjectSet(Set<Subject> subjectSet) {
this.subjectSet = subjectSet;
}
} }
@@ -1,5 +1,8 @@
package dev.ksan.etfoglasiserver.dto; package dev.ksan.etfoglasiserver.dto;
import lombok.AllArgsConstructor;
@AllArgsConstructor
public class UserLoginDTO { public class UserLoginDTO {
private String email; private String email;
@@ -0,0 +1,41 @@
package dev.ksan.etfoglasiserver.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter // ← add class-level getter
@Entity
@Table(
name = "device_tokens",
uniqueConstraints = {
@UniqueConstraint(columnNames = "fcm_token")
}
)
public class DeviceToken {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Setter
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Setter
@Column(name = "fcm_token", nullable = false)
private String fcmToken;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
@PreUpdate
public void touch() {
this.updatedAt = LocalDateTime.now();
}
}
@@ -3,6 +3,5 @@ package dev.ksan.etfoglasiserver.model;
public enum NotificationMethod { public enum NotificationMethod {
NO_NOTIFICATION, NO_NOTIFICATION,
EMAIL,
PUSH_NOTIFICATION PUSH_NOTIFICATION
} }
@@ -0,0 +1,8 @@
package dev.ksan.etfoglasiserver.model;
import lombok.Data;
@Data
public class RegisterTokenRequest {
private String token;
}
@@ -1,6 +1,8 @@
package dev.ksan.etfoglasiserver.model; package dev.ksan.etfoglasiserver.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -9,13 +11,18 @@ import java.util.UUID;
@Table(name = "subjects") @Table(name = "subjects")
public class Subject { public class Subject {
@Getter
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@Column(columnDefinition = "uuid") @Column(columnDefinition = "uuid")
private UUID id; private UUID id;
@Getter
@Setter
@Column private long code; @Column private long code;
@Getter
@Setter
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@@ -26,23 +33,4 @@ public class Subject {
public Subject(){ public Subject(){
} }
public UUID getId() {
return id;
}
public long getCode() {
return code;
}
public void setCode(long code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
} }
@@ -9,6 +9,8 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Setter;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -18,9 +20,11 @@ public class SubjectAlt {
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
private UUID id; private UUID id;
@Setter
@Column(nullable = false) @Column(nullable = false)
String name; String name;
@Setter
@ManyToOne(fetch = FetchType.EAGER) @ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(nullable = false) @JoinColumn(nullable = false)
private Subject subject; private Subject subject;
@@ -32,22 +36,15 @@ public class SubjectAlt {
} }
public UUID getId() { public UUID getId() {
return id; return this.id;
} }
public String getName() { public String getName() {
return name; return this.name;
}
public void setName(String name) {
this.name = name;
} }
public Subject getSubject() { public Subject getSubject() {
return subject; return this.subject;
} }
public void setSubject(Subject subject) {
this.subject = subject;
}
} }
@@ -3,6 +3,8 @@ package dev.ksan.etfoglasiserver.model;
import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -21,21 +23,43 @@ public class User {
private String password; private String password;
@Column( @Column(
nullable = false, nullable = false,
updatable = false, updatable = false,
insertable = false, insertable = false,
columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP") columnDefinition = "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
)
private LocalDateTime regTime; private LocalDateTime regTime;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "notification_type", nullable = false) @Column(name = "notification_type", nullable = false)
private NotificationMethod notificationType = NotificationMethod.NO_NOTIFICATION; private NotificationMethod notificationType =
NotificationMethod.PUSH_NOTIFICATION;
@ManyToMany(fetch = FetchType.LAZY ) @ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "user-subject", joinColumns = @JoinColumn(name = "user_id"), @JoinTable(
inverseJoinColumns = @JoinColumn(name = "subject_id")) name = "user_subject",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "subject_id")
)
private Set<Subject> subjectSet; private Set<Subject> subjectSet;
@OneToMany(
mappedBy = "user",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private List<DeviceToken> deviceTokens = new ArrayList<>();
public void addDeviceToken(DeviceToken token) {
deviceTokens.add(token);
token.setUser(this);
}
public void removeDeviceToken(DeviceToken token) {
deviceTokens.remove(token);
token.setUser(null);
}
public User() {} public User() {}
public User(UserCreationDTO user) { public User(UserCreationDTO user) {
@@ -89,4 +113,5 @@ public class User {
public NotificationMethod getNotificationMethod() { public NotificationMethod getNotificationMethod() {
return notificationType; return notificationType;
} }
} }
@@ -0,0 +1,20 @@
package dev.ksan.etfoglasiserver.repository;
import dev.ksan.etfoglasiserver.model.DeviceToken;
import dev.ksan.etfoglasiserver.model.Entry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface DeviceTokenRepo extends JpaRepository<DeviceToken, UUID> {
List<DeviceToken> findByUserId(UUID userId);
Optional<DeviceToken> findByFcmToken(String fcmToken);
void deleteByFcmToken(String fcmToken);
}
@@ -0,0 +1,31 @@
package dev.ksan.etfoglasiserver.service;
import dev.ksan.etfoglasiserver.model.DeviceToken;
import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.repository.DeviceTokenRepo;
import dev.ksan.etfoglasiserver.repository.UserRepo;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class DeviceTokenService {
private final DeviceTokenRepo deviceTokenRepo;
private final UserRepo userRepo;
public void registerToken(String email, String token) {
User user = userRepo.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found"));
DeviceToken deviceToken = deviceTokenRepo
.findByFcmToken(token)
.orElse(new DeviceToken());
deviceToken.setUser(user);
deviceToken.setFcmToken(token);
deviceTokenRepo.save(deviceToken);
}
}
@@ -43,6 +43,8 @@ public class EntryService {
Entry newEntry = new Entry(entry); Entry newEntry = new Entry(entry);
newEntry.setSubject(subject); newEntry.setSubject(subject);
entryRepo.save(newEntry); entryRepo.save(newEntry);
subjectService.notifyAsync(newEntry);
} }
public void addEntry(Entry entry) { public void addEntry(Entry entry) {
@@ -63,7 +65,7 @@ public class EntryService {
System.out.println("Duplicate"); System.out.println("Duplicate");
return; return;
} }
subjectService.notify(entry); subjectService.notifyAsync(entry);
} }
public void updateEntry(EntryDTO entry) { public void updateEntry(EntryDTO entry) {
@@ -1,42 +1,31 @@
package dev.ksan.etfoglasiserver.service; package dev.ksan.etfoglasiserver.service;
import dev.ksan.etfoglasiserver.model.User;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import java.security.NoSuchAlgorithmException;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class JWTService { public class JWTService {
//TODO add persistent token env variable @Value("${jwt.secret}")
private String secretKey = ""; private String secretKey;
private JWTService() {
try {
KeyGenerator keyGen = KeyGenerator.getInstance("HmacSHA256");
SecretKey secKey = keyGen.generateKey();
secretKey = Base64.getEncoder().encodeToString(secKey.getEncoded());
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
public String generateToken(String email) { public String generateToken(String email) {
Map<String, Object> claims = new HashMap<>(); Map<String, Object> claims = new HashMap<>();
return Jwts.builder().claims().add(claims).subject(email) return Jwts.builder().claims().add(claims).subject(email)
.issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+60*60*300)) .issuedAt(new Date(System.currentTimeMillis())).expiration(new Date(System.currentTimeMillis()+1000L*60*60*24*60))
.and().signWith(getKey()).compact(); .and().signWith(getKey()).compact();
} }
@@ -0,0 +1,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; package dev.ksan.etfoglasiserver.service;
import com.google.firebase.messaging.FirebaseMessagingException;
import dev.ksan.etfoglasiserver.model.Entry; import dev.ksan.etfoglasiserver.model.Entry;
import dev.ksan.etfoglasiserver.model.NotificationMethod; import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
@@ -8,22 +9,22 @@ import dev.ksan.etfoglasiserver.repository.SubjectRepo;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import dev.ksan.etfoglasiserver.repository.UserRepo;
import org.simplejavamail.api.mailer.Mailer;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class SubjectService { public class SubjectService {
@Autowired private SubjectRepo subjectRepo; @Autowired private SubjectRepo subjectRepo;
@Autowired @Autowired
private UserRepo userRepo; private NotificationService notificationService;
@Autowired @Autowired
private UserService userService; private UserService userService;
@Autowired
private MailService mailService;
public List<Subject> getSubjects() { public List<Subject> getSubjects() {
return subjectRepo.findAll(); return subjectRepo.findAll();
@@ -67,32 +68,39 @@ public class SubjectService {
return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found")); return subjectRepo.findById(id).orElseThrow(() -> new RuntimeException("Subject not found"));
} }
public void notify(Entry entry) {
List<User> users = userService.findUsersBySubjectId(entry.getSubject().getId());
if(users == null || users.isEmpty()) return; //TODO move this and make an EventListener for this
for(User user : users) {
if(user.getNotificationMethod() == NotificationMethod.EMAIL) @Async
this.sendEmailNotification(entry, user); public void notifyAsync(Entry entry) {
if(user.getNotificationMethod() == NotificationMethod.PUSH_NOTIFICATION)
this.sendPushNotification(entry, user); // The scraper couldn't always match an announcement to a known subject, in
System.out.println(user.getEmail()); // 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.PUSH_NOTIFICATION)
continue;
try{
notificationService.sendToUser(user, entry.getTitle(), entry.getParagraph());
}catch (FirebaseMessagingException e) {
e.printStackTrace();
}
} }
} }
public void sendEmailNotification(Entry entry,User user) {
mailService.sendEmail(user.getEmail(),entry.getTitle(),entry.getParagraph()); public long count() {
return subjectRepo.count();
} }
//todo
public void sendPushNotification(Entry entry,User user) {
System.out.println("Sending push notification");
}
public long count() {
return subjectRepo.count();
}
} }
@@ -4,6 +4,7 @@ import dev.ksan.etfoglasiserver.dto.JwtResponseDTO;
import dev.ksan.etfoglasiserver.dto.UserCreationDTO; import dev.ksan.etfoglasiserver.dto.UserCreationDTO;
import dev.ksan.etfoglasiserver.dto.UserDTO; import dev.ksan.etfoglasiserver.dto.UserDTO;
import dev.ksan.etfoglasiserver.dto.UserLoginDTO; import dev.ksan.etfoglasiserver.dto.UserLoginDTO;
import dev.ksan.etfoglasiserver.model.NotificationMethod;
import dev.ksan.etfoglasiserver.model.Subject; import dev.ksan.etfoglasiserver.model.Subject;
import dev.ksan.etfoglasiserver.model.User; import dev.ksan.etfoglasiserver.model.User;
import dev.ksan.etfoglasiserver.repository.SubjectRepo; import dev.ksan.etfoglasiserver.repository.SubjectRepo;
@@ -23,6 +24,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service @Service
@Transactional @Transactional
@@ -61,8 +63,8 @@ public class UserService {
} }
} }
public UserDTO updateUser(UserCreationDTO user) { public UserDTO updateUser(String email, UserCreationDTO user) {
Optional<User> existingUserOpt = userRepo.findByEmail(user.getEmail()); Optional<User> existingUserOpt = userRepo.findByEmail(email);
if (userRepo.findByEmail(user.getNewEmail()).isPresent()) { if (userRepo.findByEmail(user.getNewEmail()).isPresent()) {
throw new RuntimeException("Email taken"); throw new RuntimeException("Email taken");
@@ -96,8 +98,12 @@ public class UserService {
} else throw new RuntimeException("User not found"); } else throw new RuntimeException("User not found");
} }
public void deleteUser(UUID userId) { public void deleteUser(String email) {
userRepo.deleteById(userId); User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
user.getSubjectSet().clear();
userRepo.save(user);
userRepo.delete(user);
} }
public UserDTO register(UserCreationDTO user) { public UserDTO register(UserCreationDTO user) {
@@ -142,37 +148,36 @@ public class UserService {
throw new RuntimeException("Invalid email"); throw new RuntimeException("Invalid email");
} }
public JwtResponseDTO verify(UserLoginDTO user) { public JwtResponseDTO verify(UserLoginDTO user) {
Authentication authentication = authManager.authenticate( authManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getEmail(), user.getPassword()) new UsernamePasswordAuthenticationToken(
); user.getEmail(),
user.getPassword()
)
);
if (!authentication.isAuthenticated()) { User foundUser = userRepo.findByEmail(user.getEmail())
throw new BadCredentialsException("Invalid username or password"); .orElseThrow(() -> new RuntimeException("User not found"));
}
String token = jwtService.generateToken(user.getEmail()); String token = jwtService.generateToken(user.getEmail());
User foundUser = userRepo.findByEmail(user.getEmail()) return new JwtResponseDTO(
.orElseThrow(() -> new RuntimeException("User not found after authentication")); token,
foundUser.getEmail(),
return new JwtResponseDTO( foundUser.getId().toString(),
token, HttpStatus.OK.toString()
foundUser.getEmail(), );
foundUser.getId().toString(), }
HttpStatus.OK.toString()
);
}
public Optional<User> getAccountByEmail(String email) { public Optional<User> getAccountByEmail(String email) {
return userRepo.findByEmail(email); return userRepo.findByEmail(email);
} }
public UserDTO addSubject(UUID userId, UUID subjectId) { public UserDTO addSubject(String email, UUID subjectId) {
User user = userRepo.findById(userId) User user = userRepo.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
Subject subject = subjectRepo.findById(subjectId) Subject subject = subjectRepo.findById(subjectId)
@@ -185,9 +190,9 @@ public class UserService {
return getUserDTO(user); return getUserDTO(user);
} }
public UserDTO removeSubject(UUID userId, UUID subjectId) { public UserDTO removeSubject(String email, UUID subjectId) {
User user = userRepo.findById(userId) User user = userRepo.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
Subject subject = subjectRepo.findById(subjectId) Subject subject = subjectRepo.findById(subjectId)
@@ -200,4 +205,11 @@ public class UserService {
return getUserDTO(user); return getUserDTO(user);
} }
public void updateNotificationType(String email, NotificationMethod notificationType) {
User user = userRepo.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
user.setNotificationMethod(notificationType);
userRepo.save(user);
}
} }
@@ -1,5 +0,0 @@
spring.application.name=etfoglasi-server
spring.datasource.url=jdbc:postgresql://localhost:5432/etfo-db
spring.datasource.username=test
spring.datasource.password=test
@@ -0,0 +1,8 @@
spring.application.name=
spring.datasource.url=
spring.datasource.username=
spring.datasource.password=
# maybe use > openssl rand -base64 32
jwt.secret=
@@ -1,13 +0,0 @@
package dev.ksan.etfoglasiserver;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class EtfoglasiServerApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,44 @@
package dev.ksan.etfoglasiserver.controller;
import dev.ksan.etfoglasiserver.model.RegisterTokenRequest;
import dev.ksan.etfoglasiserver.service.DeviceTokenService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DeviceTokenControllerTest {
@Mock
DeviceTokenService deviceTokenService;
@InjectMocks
DeviceTokenController controller;
@Test
void register_savesNewToken() {
UserDetails principal = mock(UserDetails.class);
when(principal.getUsername()).thenReturn("test@test.com");
RegisterTokenRequest req = new RegisterTokenRequest();
req.setToken("fcm-123");
ResponseEntity<Void> res = controller.register(req, principal);
assertEquals(204, res.getStatusCode().value());
verify(deviceTokenService).registerToken(
"test@test.com",
"fcm-123"
);
}
}
@@ -0,0 +1,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=
+10 -7
View File
@@ -1,25 +1,27 @@
{ {
"expo": { "expo": {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"slug": "etfoglasi-frontend", "slug": "etfoglasi",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "etfoglasifrontend", "scheme": "etfoglasi",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "newArchEnabled": true,
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true
}, },
"android": { "android": {
"googleServicesFile": "./google-services.json",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#1C1918",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png", "backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false "predictiveBackGestureEnabled": false,
"package": "dev.ksan.etfoglasi"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -39,7 +41,9 @@
"backgroundColor": "#000000" "backgroundColor": "#000000"
} }
} }
] ],
"@react-native-firebase/app",
"@react-native-firebase/messaging"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
@@ -47,4 +51,3 @@
} }
} }
} }
+68 -3
View File
@@ -1,13 +1,78 @@
import "../globals.css"; import "../globals.css";
import { Stack } from "expo-router"; import { useEffect, useState } from 'react';
import {AuthProvider} from "@/context/AuthContext"; import { Stack } from 'expo-router';
import {SafeAreaProvider} from "react-native-safe-area-context"; import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
getMessaging,
onMessage,
onNotificationOpenedApp,
getInitialNotification,
} 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() { export default function RootLayout() {
return ( return (
<AuthProvider> <AuthProvider>
<SafeAreaProvider> <SafeAreaProvider>
<NotificationSetup />
<Stack screenOptions={{ headerShown: false }} /> <Stack screenOptions={{ headerShown: false }} />
<UpdateCheck />
<Toast />
</SafeAreaProvider> </SafeAreaProvider>
</AuthProvider> </AuthProvider>
); );
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 916 KiB

+138
View File
@@ -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,
},
});
+10
View File
@@ -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;
+38 -10
View File
@@ -1,9 +1,10 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import Toast from "react-native-toast-message";
import { import {
authApi, authApi,
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO, LoginPayload, RegisterPayload, setUnauthorizedHandler, subscriptionsApi, userApi, UserUpdateDTO,
} from "@/services/api"; } from "@/services/api";
type User = { type User = {
@@ -11,6 +12,7 @@ type User = {
email: string; email: string;
token: string; token: string;
subscribedSubjectIds: string[]; subscribedSubjectIds: string[];
notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
}; };
type AuthContextValue = { type AuthContextValue = {
@@ -23,6 +25,7 @@ type AuthContextValue = {
unsubscribe: (subjectId: string) => Promise<void>; unsubscribe: (subjectId: string) => Promise<void>;
isSubscribed: (subjectId: string) => boolean; isSubscribed: (subjectId: string) => boolean;
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>; updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
updateNotificationType: (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => Promise<void>;
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
@@ -43,17 +46,38 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(u); setUser(u);
}; };
useEffect(() => {
setUnauthorizedHandler(() => {
persist(null);
Toast.show({
type: 'info',
text1: 'Session expired',
text2: 'Please sign in again.',
position: 'top',
visibilityTime: 4000,
});
});
return () => setUnauthorizedHandler(null);
}, []);
const updateNotificationType = async (type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION') => {
if (!user) return;
await userApi.updateNotificationType(type, user.token);
await persist({ ...user, notificationType: type });
};
const login = async (payload: LoginPayload) => { const login = async (payload: LoginPayload) => {
const res = await authApi.login(payload); const res = await authApi.login(payload);
// Gracefully fetch subscriptions — don't block login if this fails
let subscribedSubjectIds: string[] = []; let subscribedSubjectIds: string[] = [];
let notificationType: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION' = 'PUSH_NOTIFICATION';
if (res.userId) { if (res.userId) {
try { try {
const dto = await authApi.getUser(res.userId, res.token); const dto = await authApi.getUser(res.token);
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? []; subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
} catch { notificationType = dto.notificationType ?? 'PUSH_NOTIFICATION';
// aaaa } catch (e) {
console.error('Failed to load subscriptions on login:', e);
} }
} }
@@ -62,12 +86,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
email: res.email, email: res.email,
token: res.token, token: res.token,
subscribedSubjectIds, subscribedSubjectIds,
notificationType,
}); });
}; };
const register = async (payload: RegisterPayload) => { const register = async (payload: RegisterPayload) => {
await authApi.register(payload); await authApi.register(payload);
await login({ email: payload.email, password: payload.password }); await login({
email: payload.email.trim(),
password: payload.password.trim(),
});
}; };
const updateUser = async (newEmail?: string, newPassword?: string) => { const updateUser = async (newEmail?: string, newPassword?: string) => {
@@ -91,7 +119,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const subscribe = async (subjectId: string) => { const subscribe = async (subjectId: string) => {
if (!user) return; if (!user) return;
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token); const dto = await subscriptionsApi.subscribe( subjectId, user.token);
const updated = { const updated = {
...user, ...user,
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId], subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
@@ -101,7 +129,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const unsubscribe = async (subjectId: string) => { const unsubscribe = async (subjectId: string) => {
if (!user) return; if (!user) return;
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token); const dto = await subscriptionsApi.unsubscribe( subjectId, user.token);
const updated = { const updated = {
...user, ...user,
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId), subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
@@ -113,7 +141,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
user?.subscribedSubjectIds.includes(subjectId) ?? false; user?.subscribedSubjectIds.includes(subjectId) ?? false;
return ( return (
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}> <AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser, updateNotificationType }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
+137
View File
@@ -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),
};
}
+27
View File
@@ -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';
-124
View File
@@ -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>
);
}
+1335 -432
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,18 +1,21 @@
{ {
"name": "etfoglasi-frontend", "name": "etfoglasi",
"main": "expo-router/entry", "main": "index.js",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js", "reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@notifee/react-native": "^9.1.8",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@react-native-firebase/app": "^24.0.0",
"@react-native-firebase/messaging": "^24.0.0",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
@@ -37,6 +40,7 @@
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-tailwindcss": "^1.1.11", "react-native-tailwindcss": "^1.1.11",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
"tailwindcss": "^3.4.18" "tailwindcss": "^3.4.18"
+8 -1
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, {useCallback, useState} from "react";
import { import {
View, Text, ScrollView, TouchableOpacity, View, Text, ScrollView, TouchableOpacity,
ActivityIndicator, useColorScheme, ActivityIndicator, useColorScheme,
@@ -7,6 +7,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import SearchBar from "../components/SearchBar"; import SearchBar from "../components/SearchBar";
import CollapsibleCategory from "../components/CollapsibleCategory"; import CollapsibleCategory from "../components/CollapsibleCategory";
import {useFocusEffect} from "expo-router";
const GROUPS = [ const GROUPS = [
@@ -40,6 +41,12 @@ export default function AllFeed() {
setTimeout(() => setRefreshing(false), 800); setTimeout(() => setRefreshing(false), 800);
}; };
useFocusEffect(
useCallback(() => {
handleRefresh();
}, [])
);
return ( return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}> <SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */} {/* Header */}
+9 -3
View File
@@ -25,13 +25,19 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
const accent = dark ? "#E07B45" : "#C4622D"; const accent = dark ? "#E07B45" : "#C4622D";
const submit = async () => { const submit = async () => {
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
setError(""); setError("");
if (!email || !password) { setError("Please fill in all fields."); return; } if (!trimmedEmail || !trimmedPassword) { setError("Please fill in all fields."); return; }
setLoading(true); setLoading(true);
try { try {
if (mode === "login") await login({ email, password }); if (mode === "login") await login({ email:trimmedEmail, password:trimmedPassword});
else await register({ email, password, name }); else await register({ email: trimmedEmail,password:trimmedPassword , name });
onSuccess?.(); onSuccess?.();
} catch (e: any) { } catch (e: any) {
setError(e.message ?? "Something went wrong."); setError(e.message ?? "Something went wrong.");
+30 -19
View File
@@ -23,6 +23,8 @@ type SettingRowProps = {
accent?: string; accent?: string;
}; };
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) { function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278"); const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
@@ -149,11 +151,25 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) { function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
const [showSubs, setShowSubs] = useState(false); const [showSubs, setShowSubs] = useState(false);
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user } = useAuth(); const { user, updateNotificationType } = useAuth();
const [notifications, setNotifications] = React.useState(true); const [notifications, setNotifications] = useState(
user?.notificationType === 'PUSH_NOTIFICATION'
);
const [digest, setDigest] = React.useState(false); const [digest, setDigest] = React.useState(false);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const handleNotificationToggle = async (value: boolean) => {
setNotifications(value);
try {
await updateNotificationType(
value ? 'PUSH_NOTIFICATION' : 'NO_NOTIFICATION'
);
} catch {
setNotifications(!value); // revert on failure
}
};
return ( return (
<> <>
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
@@ -197,13 +213,19 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
> >
<SectionLabel title="Notifications" /> <SectionLabel title="Notifications" />
<SettingRow <SettingRow
icon="notifications-outline" label="Push notifications (TODO)" icon="notifications-outline"
toggle toggleValue={notifications} onToggle={setNotifications} label="Push notifications"
toggle
toggleValue={notifications}
onToggle={handleNotificationToggle}
accent={dark ? "#E07B45" : "#C4622D"} accent={dark ? "#E07B45" : "#C4622D"}
/> />
<SettingRow <SettingRow
icon="mail-outline" label="Daily digest email (TODO)" icon="mail-outline"
toggle toggleValue={digest} onToggle={setDigest} label="Weekly Digest email (TODO)"
toggle
toggleValue={digest}
onToggle={setDigest}
accent={dark ? "#5E9EF4" : "#3B7DD8"} accent={dark ? "#5E9EF4" : "#3B7DD8"}
/> />
@@ -231,32 +253,21 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text> <Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}> <Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
<EditProfile onClose={() => setShowEdit(false)} /> <EditProfile onClose={() => setShowEdit(false)} />
</Modal> </Modal>
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}> <Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
<HelpSupport onClose={() => setShowHelp(false)} /> <HelpSupport onClose={() => setShowHelp(false)} />
</Modal> </Modal>
<Modal <Modal visible={showSubs} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowSubs(false)}>
visible={showSubs}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowSubs(false)}
>
<ManageSubscriptions onClose={() => setShowSubs(false)} /> <ManageSubscriptions onClose={() => setShowSubs(false)} />
</Modal> </Modal>
</> </>
); );
} }
export default function Profile() { export default function Profile() {
const dark = useColorScheme() === "dark"; const dark = useColorScheme() === "dark";
const { user, logout } = useAuth(); const { user, logout } = useAuth();
+9 -3
View File
@@ -9,6 +9,7 @@ import SearchBar from "../components/SearchBar";
import ExpandableItem from "../components/ExpandableItem"; import ExpandableItem from "../components/ExpandableItem";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { Entry, entriesApi } from "../services/api"; import { Entry, entriesApi } from "../services/api";
import {useFocusEffect} from "expo-router";
type SortMode = "time" | "subject"; type SortMode = "time" | "subject";
@@ -124,9 +125,14 @@ export default function SubscribedFeed() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [user?.subscribedSubjectIds]); }, [user?.subscribedSubjectIds?.join(',')]);
useEffect(() => { fetchFeed(); }, [fetchFeed]); // Always re-fetch when tab gains focus
useFocusEffect(
useCallback(() => {
fetchFeed();
}, [fetchFeed])
);
// Filter then group // Filter then group
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -249,7 +255,7 @@ export default function SubscribedFeed() {
<View className="items-center mt-16 px-8"> <View className="items-center mt-16 px-8">
<Text className="text-4xl mb-3">🔍</Text> <Text className="text-4xl mb-3">🔍</Text>
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}> <Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
No results for "{query}" No results for &#34;{query}&#34;
</Text> </Text>
</View> </View>
) : ( ) : (
+28
View File
@@ -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}`);
+42 -10
View File
@@ -1,11 +1,19 @@
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this const BASE_URL = process.env.EXPO_PUBLIC_API_URL+'/api';
export type NotificationType = 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION';
function authHeader(token?: string) { function authHeader(token?: string) {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
let onUnauthorized: (() => void) | null = null;
// Called once from AuthProvider so api.tsx can trigger a logout/relogin
// when the backend rejects a request due to a missing/expired/invalid token.
export function setUnauthorizedHandler(handler: (() => void) | null) {
onUnauthorized = handler;
}
async function request<T>( async function request<T>(
method: string, method: string,
path: string, path: string,
@@ -25,7 +33,14 @@ async function request<T>(
headers: { "Content-Type": "application/json", ...authHeader(token) }, headers: { "Content-Type": "application/json", ...authHeader(token) },
...(body ? { body: JSON.stringify(body) } : {}), ...(body ? { body: JSON.stringify(body) } : {}),
}); });
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); if (!res.ok) {
const errorText = await res.text().catch(() => `HTTP ${res.status}`);
console.log('Failed URL:', res.url);
console.log('Status:', res.status);
console.log('Response:', errorText);
if (res.status === 401 && token) onUnauthorized?.();
throw new Error(errorText);
}
return res.json(); return res.json();
} }
@@ -65,6 +80,7 @@ export type UserDTO = {
id: string; id: string;
email: string; email: string;
subjectSet: Subject[]; subjectSet: Subject[];
notificationType: NotificationType;
}; };
@@ -103,30 +119,46 @@ export type RegisterPayload = { email: string; password: string; name?: string }
export const authApi = { export const authApi = {
login: (p: LoginPayload) => post<JwtResponse>("/login", p), login: (p: LoginPayload) => post<JwtResponse>("/login", p),
register: (p: RegisterPayload) => post<UserDTO>("/register", p), register: (p: RegisterPayload) => post<UserDTO>("/register", p),
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body), getUser: (token: string) =>
get<UserDTO>("/users/me", token),
updateUser: (body: UserUpdateDTO, token: string) =>
request<UserDTO>("PUT", "/users/me", token, body),
deleteUser: (token: string) =>
del<void>("/users/me", token),
}; };
export const userApi = {
updateNotificationType: (
type: 'PUSH_NOTIFICATION' | 'NO_NOTIFICATION',
token: string
) => post<void>('/users/me/notification-type', { notificationType: type }, token),
};
export const subjectsApi = { export const subjectsApi = {
getAll: (token?: string) => get<Subject[]>("/subjects", token), getAll: (token?: string) => get<Subject[]>("/subjects", token),
}; };
export const subscriptionsApi = { export const subscriptionsApi = {
subscribe: (userId: string, subjectId: string, token: string) => subscribe: (subjectId: string, token: string) =>
put<UserDTO>( put<UserDTO>(
`/users/${userId}/subjects/${subjectId}`, `/users/me/subjects/${subjectId}`,
{}, // body placeholder {},
token token
), ),
unsubscribe: (userId: string, subjectId: string, token: string) => unsubscribe: (subjectId: string, token: string) =>
del<UserDTO>( del<UserDTO>(
`/users/${userId}/subjects/${subjectId}`, `/users/me/subjects/${subjectId}`,
token token
), ),
}; };
export const entriesApi = { export const entriesApi = {
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) => getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
get<SpringPage<Entry>>("/entries", undefined, params), get<SpringPage<Entry>>("/entries", undefined, params),
+55
View File
@@ -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' },
},
});
}
+3
View File
@@ -0,0 +1,3 @@
{
"version": 0
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB