feat(chat): push notifications for DMs + rooms

Backend:
- Prisma PushToken model + chat_push_enabled flag on profiles
- Migration 20260530_add_push_tokens (push_tokens table + profile flag)
- Service sendChatPush with expo-server-sdk (auto-disable invalid tokens)
- Fire-and-forget push trigger in sendDirectMessage + createRoomMessage
- API POST /users/me/push-token (upsert) + DELETE (soft-disable)

Client (rebreak-native):
- usePushTokenRegistration hook: permission, getExpoPushTokenAsync,
  Android channel 'chat', POST to backend; idempotent per session
- Notification tap deep-link: dm -> /dm?userId, room -> /room?roomId

Deploy:
- run_quiet spinner for silent altool/xcodebuild/gradle phases
- Release-notes pipeline (--notes flag / NEXT_RELEASE.md / interactive)
  archived to CHANGELOG.md, printed with ASC + Play Console links
- Default version bump ON (--no-bump opt-out), build cleanup
- NEXT_RELEASE.md with push-notification release note
This commit is contained in:
chahinebrini 2026-05-30 08:16:45 +02:00
parent d31d5b3b83
commit 38df6fc79d
14 changed files with 1910 additions and 530 deletions

View File

@ -1,6 +1,7 @@
# Changelog
All notable changes to rebreak-native will be documented in this file.
## v0.3.13 (Build 26 / versionCode 16) — 2026-05-30\n\nneue push für chat\n
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
Versioning: `version` follows SemVer, `versionCode` is monotonically increasing.

View File

@ -0,0 +1 @@
Push-Notifications für Chat: Du erhältst jetzt Pushes bei neuen Direkt-Nachrichten und Raum-Nachrichten. Abschaltbar in den Einstellungen.

View File

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { AppState, I18nManager } from 'react-native';
I18nManager.allowRTL(true);
import { Stack } from 'expo-router';
import { Stack, router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
@ -31,6 +31,7 @@ import { BrandSplash } from '../components/BrandSplash';
import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
@ -55,7 +56,7 @@ const queryClient = new QueryClient({
});
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const { loading, init, user } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const colorScheme = useThemeStore((s) => s.colorScheme);
const initLanguage = useLanguageStore((s) => s.init);
@ -71,6 +72,27 @@ function RootLayoutInner() {
Nunito_800ExtraBold,
});
// Push-Token-Registration nach Login (idempotent)
usePushTokenRegistration(user?.id);
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat
useEffect(() => {
const sub = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data as
| { type?: 'dm' | 'room'; targetId?: string }
| undefined;
if (!data?.type || !data.targetId) return;
if (data.type === 'dm') {
router.push({ pathname: '/dm', params: { userId: data.targetId } });
} else if (data.type === 'room') {
router.push({ pathname: '/room', params: { roomId: data.targetId } });
}
},
);
return () => sub.remove();
}, []);
useEffect(() => {
init();
initTheme();

813
apps/rebreak-native/deploy.sh Executable file
View File

@ -0,0 +1,813 @@
#!/bin/bash
# deploy.sh — ReBreak Multi-Platform Release Pipeline
#
# SUBCOMMANDS:
# ./deploy.sh default: all (testflight + mdm + android)
# ./deploy.sh testflight iOS TestFlight via App Store Connect
# ./deploy.sh mdm iOS Ad-Hoc IPA + scp Upload zu MDM-Server
# ./deploy.sh android Android APK/AAB via Gradle + Play Console
# ./deploy.sh all Alle drei Targets
#
# FLAGS:
# --no-bump Build-Number NICHT bumpen (Default: bump an)
# --version X.Y.Z Explizite Version setzen
# --build N Explizite iOS Build-Nummer
# --android-version-code N Override Android versionCode
# --notes "text" Release-Notes für diese Version (TestFlight + Play Console)
# --skip-clean clean-ios.sh überspringen (iOS)
# --skip-validate altool --validate-app überspringen (TF)
# --skip-submit Play-Console-Submit überspringen (Android)
# --keep-build Build-Artefakte NICHT löschen (Default: cleanup nach Submit)
# --dry-run Alles simulieren, nichts ausführen
# -h, --help Diese Hilfe anzeigen
#
# BEISPIELE:
# # Full Release (alle Plattformen — bumpt + cleanup automatisch):
# ./deploy.sh
#
# # Nur Android build (kein Submit, Build behalten):
# ./deploy.sh android --skip-submit --keep-build
#
# # Nur iOS TestFlight mit expliziter Version (ohne Bump):
# ./deploy.sh testflight --no-bump --version 0.4.0 --build 26
#
# # Dry-Run zum Testen:
# ./deploy.sh all --dry-run
#
# CREDENTIALS:
# iOS TestFlight:
# - APPLE_APP_SPECIFIC_PASSWORD (oder)
# - ASC_API_KEY_PATH + ASC_API_KEY_ID + ASC_API_KEY_ISSUER
# iOS MDM:
# - SSH-Access zu rebreak-mdm Server
# Android:
# - android/key.properties (signing)
# - android/app/*.keystore (release keystore)
# - PLAY_SERVICE_ACCOUNT_JSON (für --submit)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_CONFIG="$SCRIPT_DIR/app.config.ts"
PACKAGE_JSON="$SCRIPT_DIR/package.json"
LOG_DIR="$SCRIPT_DIR/tmp/deploy-logs"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
# ═══════════════════════════════════════════════════════════════════════════
# Color Output (brew-style)
# ═══════════════════════════════════════════════════════════════════════════
if [[ -t 1 ]]; then
BOLD=$(tput bold)
GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3)
RED=$(tput setaf 1)
BLUE=$(tput setaf 4)
RESET=$(tput sgr0)
else
BOLD="" GREEN="" YELLOW="" RED="" BLUE="" RESET=""
fi
log() { echo "${BLUE}==>${RESET} ${BOLD}$*${RESET}"; }
ok() { echo "${GREEN}${RESET} $*"; }
warn() { echo "${YELLOW}${RESET} $*" >&2; }
error() { echo "${RED}${RESET} ${BOLD}$*${RESET}" >&2; }
die() { error "$*"; exit 1; }
section() {
echo ""
echo "${BOLD}────────────────────────────────────────────────────────────${RESET}"
echo "${BOLD}$*${RESET}"
echo "${BOLD}────────────────────────────────────────────────────────────${RESET}"
}
run() {
if $DRY_RUN; then
echo "${YELLOW}[DRY-RUN]${RESET} $*"
return 0
else
"$@"
fi
}
# run_quiet "Label" <log-file> <cmd...>
# Runs cmd silently with a spinner + elapsed time. On error dumps last 40 log
# lines and exits. With --verbose / non-TTY: streams full output normally.
run_quiet() {
local label="$1"; shift
local logfile="$1"; shift
if $DRY_RUN; then
echo "${YELLOW}[DRY-RUN]${RESET} $label: $*"
return 0
fi
if $VERBOSE || [[ ! -t 1 ]]; then
log "$label"
"$@" 2>&1 | tee "$logfile"
return ${PIPESTATUS[0]}
fi
local start=$SECONDS
local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local i=0 pid elapsed frame
( "$@" >"$logfile" 2>&1 ) &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
elapsed=$((SECONDS - start))
frame="${spin:i%10:1}"
i=$((i + 1))
# \r + \033[K = carriage return + clear line to end
printf '\r\033[K%s %s==>%s %s %s(%ds)%s' \
"$frame" "$BLUE" "$RESET" "$label" "$YELLOW" "$elapsed" "$RESET" >&2
sleep 0.1
done
wait "$pid"
local rc=$?
elapsed=$((SECONDS - start))
printf '\r\033[K' >&2 # Clear spinner line
if [[ $rc -eq 0 ]]; then
ok "$label ${YELLOW}(${elapsed}s)${RESET}"
else
error "$label fehlgeschlagen nach ${elapsed}s (exit $rc)"
echo "" >&2
echo "${BOLD}── Letzte Log-Zeilen (${logfile}) ──${RESET}" >&2
tail -40 "$logfile" >&2
echo "${BOLD}────────────────────────────────────${RESET}" >&2
echo "Voller Log: $logfile" >&2
exit $rc
fi
}
# ═══════════════════════════════════════════════════════════════════════════
# Flag Parsing
# ═══════════════════════════════════════════════════════════════════════════
COMMAND="${1:-all}"
shift || true
DO_MDM=false
DO_TF=false
DO_ANDROID=false
case "$COMMAND" in
all) DO_MDM=true; DO_TF=true; DO_ANDROID=true ;;
testflight|tf) DO_TF=true ;;
mdm|adhoc) DO_MDM=true ;;
android) DO_ANDROID=true ;;
-h|--help)
awk '/^#!/{next} /^#/{sub(/^# ?/, ""); print; next} {exit}' "$0"
exit 0 ;;
*)
error "Unbekanntes Subcommand: $COMMAND"
echo ""
echo "Verfügbare Commands:"
echo " all Alle Plattformen (testflight + mdm + android)"
echo " testflight Nur iOS TestFlight"
echo " mdm Nur iOS Ad-Hoc/MDM"
echo " android Nur Android"
echo ""
echo "Nutze --help für Details"
exit 1
;;
esac
# Bump default ON — Android requires new versionCode for every upload,
# TestFlight requires unique build per version. --no-bump to opt out.
BUMP_IOS=true
BUMP_ANDROID=true
EXPLICIT_VERSION=""
EXPLICIT_BUILD=""
ANDROID_VERSION_CODE_OVERRIDE=""
RELEASE_NOTES=""
SKIP_CLEAN=false
SKIP_VALIDATE=false
SKIP_SUBMIT=false
KEEP_BUILD=false
DRY_RUN=false
VERBOSE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--bump) shift ;; # default on — silently accepted for backward compat
--no-bump) BUMP_IOS=false; BUMP_ANDROID=false; shift ;;
--version) EXPLICIT_VERSION="$2"; shift 2 ;;
--build) EXPLICIT_BUILD="$2"; shift 2 ;;
--android-version-code) ANDROID_VERSION_CODE_OVERRIDE="$2"; shift 2 ;;
--notes) RELEASE_NOTES="$2"; shift 2 ;;
--skip-clean) SKIP_CLEAN=true; shift ;;
--skip-validate) SKIP_VALIDATE=true; shift ;;
--skip-submit) SKIP_SUBMIT=true; shift ;;
--keep-build) KEEP_BUILD=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help)
awk '/^#!/{next} /^#/{sub(/^# ?/, ""); print; next} {exit}' "$0"
exit 0 ;;
*) die "Unbekannter Flag: $1 (--help für Hilfe)" ;;
esac
done
# ═══════════════════════════════════════════════════════════════════════════
# ENV & Paths
# ═══════════════════════════════════════════════════════════════════════════
REBREAK_TEAM_ID="${REBREAK_TEAM_ID:-84BQ7MTFYK}"
MDM_SERVER="${MDM_SERVER:-rebreak-mdm}"
INSTALL_BASE_URL="${INSTALL_BASE_URL:-https://mdm.rebreak.org/install}"
export REBREAK_ENABLE_FAMILY_CONTROLS="${REBREAK_ENABLE_FAMILY_CONTROLS:-1}"
export EXPO_PUBLIC_ENABLE_DEBUG="${EXPO_PUBLIC_ENABLE_DEBUG:-0}"
IOS_DIR="$SCRIPT_DIR/ios"
ANDROID_DIR="$SCRIPT_DIR/android"
ARCHIVE_PATH="/tmp/Rebreak.xcarchive"
ADHOC_EXPORT_DIR="/tmp/Rebreak-ipa"
TF_EXPORT_DIR="/tmp/Rebreak-tf"
ADHOC_IPA="$ADHOC_EXPORT_DIR/Rebreak.ipa"
TF_IPA="$TF_EXPORT_DIR/Rebreak.ipa"
ADHOC_EXPORT_OPTIONS="$SCRIPT_DIR/build-config/exportOptions-adhoc.plist"
TF_EXPORT_OPTIONS="$SCRIPT_DIR/build-config/exportOptions-tf.plist"
WORKSPACE="$IOS_DIR/ReBreak.xcworkspace"
SCHEME="ReBreak"
APPLE_ID_EMAIL="${APPLE_ID_EMAIL:-chahinebrini@gmail.com}"
APPLE_APP_SPECIFIC_PASSWORD="${APPLE_APP_SPECIFIC_PASSWORD:-}"
ASC_API_KEY_PATH="${ASC_API_KEY_PATH:-}"
ASC_API_KEY_ID="${ASC_API_KEY_ID:-}"
ASC_API_KEY_ISSUER="${ASC_API_KEY_ISSUER:-}"
PLAY_SERVICE_ACCOUNT_JSON="${PLAY_SERVICE_ACCOUNT_JSON:-$HOME/secrets/rebreak-play-service-account.json}"
mkdir -p "$LOG_DIR" 2>/dev/null || true
# ═══════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════
get_current_version() {
grep -E '"version"' "$PACKAGE_JSON" | head -1 \
| sed -E 's/[^"]*"version"[^"]*"([^"]+)".*/\1/' || echo "0.0.0"
}
get_current_build_number() {
grep -E 'buildNumber:' "$APP_CONFIG" \
| sed -E 's/[^:]*:[^"]*"([0-9]+)".*/\1/' || echo "0"
}
get_current_version_code() {
grep -E 'versionCode:' "$APP_CONFIG" \
| sed -E 's/[^:]*:[^0-9]*([0-9]+).*/\1/' || echo "0"
}
bump_ios_version() {
log "iOS Version Bump..."
local current_version
current_version=$(get_current_version)
local current_build
current_build=$(get_current_build_number)
local new_version="$current_version"
local new_build
if [[ -n "$EXPLICIT_VERSION" ]]; then
new_version="$EXPLICIT_VERSION"
fi
if [[ -n "$EXPLICIT_BUILD" ]]; then
new_build="$EXPLICIT_BUILD"
else
new_build=$((current_build + 1))
fi
echo " $current_version (Build $current_build) → $new_version (Build $new_build)"
if ! $DRY_RUN; then
# Update package.json version
if [[ "$new_version" != "$current_version" ]]; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" "$PACKAGE_JSON"
else
sed -i "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" "$PACKAGE_JSON"
fi
fi
# Update buildNumber in app.config.ts
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s/buildNumber: \"$current_build\"/buildNumber: \"$new_build\"/" "$APP_CONFIG"
else
sed -i "s/buildNumber: \"$current_build\"/buildNumber: \"$new_build\"/" "$APP_CONFIG"
fi
# Update Extension Info.plists
local ext_plists=(
"$SCRIPT_DIR/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist"
"$SCRIPT_DIR/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist"
"$SCRIPT_DIR/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist"
)
for plist in "${ext_plists[@]}"; do
if [[ -f "$plist" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $new_version" "$plist" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $new_build" "$plist" 2>/dev/null || true
fi
done
ok "iOS Version aktualisiert"
fi
}
bump_android_version() {
log "Android versionCode Bump..."
local current_version_code
current_version_code=$(get_current_version_code)
local new_version_code
if [[ -n "$ANDROID_VERSION_CODE_OVERRIDE" ]]; then
new_version_code="$ANDROID_VERSION_CODE_OVERRIDE"
else
new_version_code=$((current_version_code + 1))
fi
echo " versionCode: $current_version_code$new_version_code"
if ! $DRY_RUN; then
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s/versionCode: $current_version_code,/versionCode: $new_version_code,/" "$APP_CONFIG"
else
sed -i "s/versionCode: $current_version_code,/versionCode: $new_version_code,/" "$APP_CONFIG"
fi
ok "Android versionCode aktualisiert"
fi
}
# ═══════════════════════════════════════════════════════════════════════════
# Release Notes Collection
# ═══════════════════════════════════════════════════════════════════════════
collect_release_notes() {
# Priority: --notes flag > NEXT_RELEASE.md > interactive prompt
local next_release_file="$SCRIPT_DIR/NEXT_RELEASE.md"
if [[ -n "$RELEASE_NOTES" ]]; then
log "Release-Notes: aus --notes Flag"
return
fi
if [[ -f "$next_release_file" ]] && [[ -s "$next_release_file" ]]; then
RELEASE_NOTES="$(cat "$next_release_file")"
log "Release-Notes: aus $next_release_file"
return
fi
# Interactive prompt (nur wenn TTY)
if [[ -t 0 ]] && [[ -t 1 ]] && ! $DRY_RUN; then
echo ""
echo "${BOLD}Release-Notes für diese Version (optional):${RESET}"
echo "${YELLOW}Tipp: Multi-line mit Strg-D beenden, oder ENTER für skip${RESET}"
echo ""
# Read multi-line input until EOF (Ctrl-D) or empty line
local input_lines=()
local line
while IFS= read -r line; do
if [[ -z "$line" ]] && [[ ${#input_lines[@]} -eq 0 ]]; then
# First line empty = skip
break
fi
input_lines+=("$line")
done
if [[ ${#input_lines[@]} -gt 0 ]]; then
RELEASE_NOTES="$(printf '%s\n' "${input_lines[@]}")"
log "Release-Notes: interaktiv erfasst"
fi
fi
}
archive_release_notes_to_changelog() {
[[ -z "$RELEASE_NOTES" ]] && return
local changelog="$SCRIPT_DIR/CHANGELOG.md"
local version build version_code
version="$(get_current_version)"
build="$(get_current_build_number)"
version_code="$(get_current_version_code)"
local date_stamp
date_stamp="$(date +%Y-%m-%d)"
local header="## v${version} (Build ${build} / versionCode ${version_code}) — ${date_stamp}"
local entry="${header}\n\n${RELEASE_NOTES}\n"
if [[ ! -f "$changelog" ]]; then
# Create new CHANGELOG.md with header
echo "# Changelog" > "$changelog"
echo "" >> "$changelog"
echo "Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert." >> "$changelog"
echo "" >> "$changelog"
fi
# Prepend new entry after header (assumes "# Changelog" on line 1)
if $DRY_RUN; then
log "[DRY-RUN] Würde Release-Notes in $changelog archivieren"
else
# Use temporary file to prepend
local temp_file
temp_file="$(mktemp)"
{
head -3 "$changelog" 2>/dev/null || echo -e "# Changelog\n"
echo "$entry"
tail -n +4 "$changelog" 2>/dev/null || true
} > "$temp_file"
mv "$temp_file" "$changelog"
ok "Release-Notes in $changelog archiviert"
fi
# Clear NEXT_RELEASE.md if it was used
local next_release_file="$SCRIPT_DIR/NEXT_RELEASE.md"
if [[ -f "$next_release_file" ]] && ! $DRY_RUN; then
rm "$next_release_file"
ok "NEXT_RELEASE.md geleert"
fi
}
# ═══════════════════════════════════════════════════════════════════════════
# iOS Ad-Hoc / MDM Pipeline
# ═══════════════════════════════════════════════════════════════════════════
deploy_mdm() {
section "iOS Ad-Hoc (MDM)"
# Preflight
command -v xcodebuild >/dev/null 2>&1 || die "xcodebuild nicht gefunden"
command -v ssh >/dev/null 2>&1 || die "ssh nicht gefunden"
command -v scp >/dev/null 2>&1 || die "scp nicht gefunden"
[[ -f "$ADHOC_EXPORT_OPTIONS" ]] || die "ExportOptions nicht gefunden: $ADHOC_EXPORT_OPTIONS"
[[ -d "$IOS_DIR" ]] || die "ios/ nicht gefunden — expo prebuild zuerst ausführen"
log "Prüfe SSH-Verbindung zu $MDM_SERVER..."
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "$MDM_SERVER" "echo ok" >/dev/null 2>&1; then
die "SSH zu $MDM_SERVER fehlgeschlagen — VPN oder SSH-Key prüfen"
fi
ok "SSH OK"
# Clean
if ! $SKIP_CLEAN; then
log "Clean iOS..."
run "$SCRIPT_DIR/clean-ios.sh"
fi
# Archive
rm -rf "$ARCHIVE_PATH"
run_quiet "Building xcarchive" "$LOG_DIR/mdm-archive-$TIMESTAMP.log" \
xcodebuild archive \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Release \
-archivePath "$ARCHIVE_PATH" \
-destination 'generic/platform=iOS' \
DEVELOPMENT_TEAM="$REBREAK_TEAM_ID"
ok "xcarchive fertig: $ARCHIVE_PATH"
# Export IPA
rm -rf "$ADHOC_EXPORT_DIR"
run_quiet "Exporting Ad-Hoc IPA" "$LOG_DIR/mdm-export-$TIMESTAMP.log" \
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$ADHOC_EXPORT_DIR" \
-exportOptionsPlist "$ADHOC_EXPORT_OPTIONS"
[[ -f "$ADHOC_IPA" ]] || die "IPA nicht erzeugt: $ADHOC_IPA"
ok "IPA exportiert: $ADHOC_IPA"
# Upload
log "Uploading zu $MDM_SERVER..."
run scp "$ADHOC_IPA" "$MDM_SERVER:/opt/nanomdm/install/Rebreak.ipa"
run scp "$ADHOC_EXPORT_DIR/manifest.plist" "$MDM_SERVER:/opt/nanomdm/install/manifest.plist"
ok "MDM-Deploy abgeschlossen"
echo ""
echo " Install-URL: $INSTALL_BASE_URL/manifest.plist"
echo " Server-seitiger systemd path-watcher triggert MDM-Push automatisch"
}
# ═══════════════════════════════════════════════════════════════════════════
# iOS TestFlight Pipeline
# ═══════════════════════════════════════════════════════════════════════════
deploy_testflight() {
section "iOS TestFlight"
# Preflight
command -v xcodebuild >/dev/null 2>&1 || die "xcodebuild nicht gefunden"
command -v xcrun >/dev/null 2>&1 || die "xcrun nicht gefunden"
[[ -f "$TF_EXPORT_OPTIONS" ]] || die "ExportOptions nicht gefunden: $TF_EXPORT_OPTIONS"
# Auth
local AUTH_MODE=""
if [[ -n "$ASC_API_KEY_PATH" && -n "$ASC_API_KEY_ID" && -n "$ASC_API_KEY_ISSUER" ]]; then
AUTH_MODE="api-key"
[[ -f "$ASC_API_KEY_PATH" ]] || die "ASC API-Key nicht gefunden: $ASC_API_KEY_PATH"
log "Auth: ASC API-Key ($ASC_API_KEY_ID)"
elif [[ -n "$APPLE_APP_SPECIFIC_PASSWORD" ]]; then
AUTH_MODE="app-specific-password"
log "Auth: App-Specific-Password ($APPLE_ID_EMAIL)"
else
die "Kein Auth konfiguriert.
Benötigt einen der folgenden Auth-Wege:
Option A — App-Specific-Password:
export APPLE_ID_EMAIL=chahinebrini@gmail.com
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
Passwort generieren: https://appleid.apple.com → Sicherheit
Option B — ASC API-Key (besser für CI):
export ASC_API_KEY_PATH=/pfad/zu/AuthKey_ABCDE12345.p8
export ASC_API_KEY_ID=ABCDE12345
export ASC_API_KEY_ISSUER=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Key erstellen: https://appstoreconnect.apple.com → Users → Integrations"
fi
# Archive lokalisieren
local USED_ARCHIVE="$ARCHIVE_PATH"
if [[ ! -d "$USED_ARCHIVE" ]]; then
# Fallback: neuestes Xcode-Archive
USED_ARCHIVE=$(find ~/Library/Developer/Xcode/Archives -name "ReBreak*.xcarchive" -type d 2>/dev/null \
| sort -r | head -1 || true)
if [[ -z "$USED_ARCHIVE" ]]; then
die "Kein xcarchive gefunden.
Entweder:
1. ./deploy.sh mdm zuerst ausführen (erzeugt $ARCHIVE_PATH)
2. Oder: ./deploy.sh all (baut MDM + TF in einem Lauf)"
fi
log "Auto-detect: $USED_ARCHIVE"
else
log "Verwende Archive: $USED_ARCHIVE"
fi
# Export IPA
rm -rf "$TF_EXPORT_DIR"
run_quiet "Exporting App-Store IPA" "$LOG_DIR/tf-export-$TIMESTAMP.log" \
xcodebuild -exportArchive \
-archivePath "$USED_ARCHIVE" \
-exportPath "$TF_EXPORT_DIR" \
-exportOptionsPlist "$TF_EXPORT_OPTIONS"
[[ -f "$TF_IPA" ]] || die "IPA nicht erzeugt: $TF_IPA"
ok "IPA exportiert: $TF_IPA"
# Validate
if ! $SKIP_VALIDATE; then
if [[ "$AUTH_MODE" == "api-key" ]]; then
run_quiet "Validating IPA (App-Store Connect)" "$LOG_DIR/tf-validate-$TIMESTAMP.log" \
xcrun altool --validate-app \
-f "$TF_IPA" \
-t ios \
--apiKey "$ASC_API_KEY_ID" \
--apiIssuer "$ASC_API_KEY_ISSUER"
else
run_quiet "Validating IPA (App-Store Connect)" "$LOG_DIR/tf-validate-$TIMESTAMP.log" \
xcrun altool --validate-app \
-f "$TF_IPA" \
-t ios \
-u "$APPLE_ID_EMAIL" \
-p "$APPLE_APP_SPECIFIC_PASSWORD"
fi
fi
# Upload
if [[ "$AUTH_MODE" == "api-key" ]]; then
run_quiet "Uploading zu App-Store Connect (TestFlight)" "$LOG_DIR/tf-upload-$TIMESTAMP.log" \
xcrun altool --upload-app \
-f "$TF_IPA" \
-t ios \
--apiKey "$ASC_API_KEY_ID" \
--apiIssuer "$ASC_API_KEY_ISSUER"
else
run_quiet "Uploading zu App-Store Connect (TestFlight)" "$LOG_DIR/tf-upload-$TIMESTAMP.log" \
xcrun altool --upload-app \
-f "$TF_IPA" \
-t ios \
-u "$APPLE_ID_EMAIL" \
-p "$APPLE_APP_SPECIFIC_PASSWORD"
fi
ok "TestFlight-Deploy abgeschlossen"
echo ""
echo " IPA erscheint automatisch in Internal Testing"
echo " Status: https://appstoreconnect.apple.com"
}
# ═══════════════════════════════════════════════════════════════════════════
# Android Pipeline
# ═══════════════════════════════════════════════════════════════════════════
deploy_android() {
section "Android Release"
# Preflight
[[ -d "$ANDROID_DIR" ]] || die "android/ nicht gefunden — expo prebuild zuerst ausführen"
local KEYSTORE_PROPS="$ANDROID_DIR/key.properties"
if [[ ! -f "$KEYSTORE_PROPS" ]]; then
error "Android Signing nicht konfiguriert"
echo ""
echo "Fehlt: $KEYSTORE_PROPS"
echo ""
echo "Setup-Schritte:"
echo ""
echo "1. Keystore generieren:"
echo " keytool -genkey -v -keystore ~/rebreak-release.keystore \\"
echo " -alias rebreak -keyalg RSA -keysize 2048 -validity 10000"
echo ""
echo "2. Keystore nach android/app/ kopieren:"
echo " cp ~/rebreak-release.keystore $ANDROID_DIR/app/"
echo ""
echo "3. key.properties erstellen:"
echo " cat > $KEYSTORE_PROPS << EOF"
echo "storePassword=<dein-password>"
echo "keyPassword=<dein-password>"
echo "keyAlias=rebreak"
echo "storeFile=rebreak-release.keystore"
echo "EOF"
echo ""
echo "4. NIEMALS committen — .gitignore prüfen"
exit 1
fi
log "Keystore-Config gefunden: $KEYSTORE_PROPS"
# Build
run_quiet "Building Release AAB (gradlew bundleRelease)" \
"$LOG_DIR/android-build-$TIMESTAMP.log" \
bash -c "cd $ANDROID_DIR && ./gradlew bundleRelease --console=plain"
local AAB="$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab"
[[ -f "$AAB" ]] || die "AAB nicht erzeugt: $AAB"
ok "AAB gebaut: $AAB"
# Submit
if ! $SKIP_SUBMIT; then
if [[ ! -f "$PLAY_SERVICE_ACCOUNT_JSON" ]]; then
warn "Play Console Service-Account-JSON fehlt: $PLAY_SERVICE_ACCOUNT_JSON"
echo ""
echo "Setup-Schritte:"
echo "1. Google Cloud Console → Service Accounts → Create → JSON-Key"
echo "2. Play Console → Setup → API-Access → Service-Account linken"
echo "3. Permissions: 'Releases' (Edit + Read)"
echo "4. JSON-Key ablegen:"
echo " mkdir -p ~/secrets"
echo " mv ~/Downloads/rebreak-play-*.json ~/secrets/rebreak-play-service-account.json"
echo ""
echo "Oder ENV setzen:"
echo " export PLAY_SERVICE_ACCOUNT_JSON=/pfad/zu/key.json"
echo ""
echo "Skipped Submit — AAB ist gebaut und bereit für manuellen Upload"
else
log "Submitting zu Play Console Internal Track..."
local eas_bin
eas_bin="$(command -v eas || true)"
if [[ -z "$eas_bin" ]]; then
die "eas-cli nicht gefunden. Installiere mit: pnpm add -g eas-cli (oder npm i -g eas-cli)"
fi
run "$eas_bin" submit --platform android \
--path "$AAB" \
--profile production \
--non-interactive
ok "Play Console Submit abgeschlossen"
fi
else
log "Submit skipped (--skip-submit)"
fi
ok "Android-Deploy abgeschlossen"
echo ""
echo " AAB: $AAB"
if ! $SKIP_SUBMIT && [[ -f "$PLAY_SERVICE_ACCOUNT_JSON" ]]; then
echo " Status: https://play.google.com/console"
fi
}
# ═══════════════════════════════════════════════════════════════════════════# Cleanup nach erfolgreichem Submit
# ═════════════════════════════════════════════════════════════════════════
human_size() {
# Cross-platform du -sh fallback for missing path
local p="$1"
[[ -e "$p" ]] || { echo "-"; return; }
du -sh "$p" 2>/dev/null | awk '{print $1}'
}
cleanup_build_artifacts() {
if $KEEP_BUILD; then
log "Cleanup skipped (--keep-build)"
return
fi
if $DRY_RUN; then
log "Cleanup (dry-run) — würde löschen:"
else
log "Cleanup Build-Artefakte..."
fi
local freed_paths=()
if $DO_MDM || $DO_TF; then
for p in "$ARCHIVE_PATH" "$ADHOC_EXPORT_DIR" "$TF_EXPORT_DIR"; do
if [[ -e "$p" ]]; then
echo " $(human_size "$p")\t$p"
freed_paths+=("$p")
fi
done
fi
if $DO_ANDROID; then
for p in "$ANDROID_DIR/app/build" "$ANDROID_DIR/build" "$ANDROID_DIR/.gradle"; do
if [[ -e "$p" ]]; then
echo " $(human_size "$p")\t$p"
freed_paths+=("$p")
fi
done
fi
if ! $DRY_RUN; then
for p in "${freed_paths[@]}"; do
rm -rf "$p" 2>/dev/null || true
done
fi
ok "Cleanup fertig (${#freed_paths[@]} Pfade)"
}
# ═════════════════════════════════════════════════════════════════════════# Main
# ═══════════════════════════════════════════════════════════════════════════
echo ""
log "ReBreak Multi-Platform Deploy"
echo ""
echo "Targets:"
if $DO_MDM; then echo " ${GREEN}${RESET} iOS Ad-Hoc/MDM"; fi
if $DO_TF; then echo " ${GREEN}${RESET} iOS TestFlight"; fi
if $DO_ANDROID; then echo " ${GREEN}${RESET} Android"; fi
echo ""
# Collect Release Notes early (before bumping)
collect_release_notes
# Version Bumping
if $BUMP_IOS && ($DO_MDM || $DO_TF); then
bump_ios_version
fi
if $BUMP_ANDROID && $DO_ANDROID; then
bump_android_version
fi
# Deploy
if $DO_MDM; then
deploy_mdm
fi
if $DO_TF; then
deploy_testflight
fi
if $DO_ANDROID; then
deploy_android
fi
# Cleanup (default on — spart Mac-Speicher; --keep-build zum Opt-out)
cleanup_build_artifacts
# Archive Release Notes
archive_release_notes_to_changelog
# Summary
echo ""
section "✓ Deploy Abgeschlossen"
echo ""
echo "Logs: $LOG_DIR"
echo ""
if [[ -n "$RELEASE_NOTES" ]]; then
echo "${BOLD}═══════════════════════════════════════════════════════════${RESET}"
echo "${BOLD}Release-Notes für v$(get_current_version) (Build $(get_current_build_number)):${RESET}"
echo "${BOLD}═══════════════════════════════════════════════════════════${RESET}"
echo ""
echo "$RELEASE_NOTES"
echo ""
echo "${BOLD}═══════════════════════════════════════════════════════════${RESET}"
echo ""
echo "${YELLOW}→ Copy-Paste in:${RESET}"
if $DO_TF; then
echo " ${BLUE}${RESET} TestFlight: https://appstoreconnect.apple.com → TestFlight → Internal Testing → 'What to Test'"
fi
if $DO_ANDROID; then
echo " ${BLUE}${RESET} Play Console: https://play.google.com/console → Internal Testing → Release-Notes"
fi
echo ""
fi
if ! $DRY_RUN; then
echo "Nächste Schritte:"
echo " - Änderungen committen (Version-Bump + CHANGELOG.md)"
echo " - Git-Tag erstellen: git tag -a v$(get_current_version) -m 'Release $(get_current_version)'"
echo " - Push: git push && git push --tags"
fi

View File

@ -0,0 +1,115 @@
/**
* Push-Token-Registration mit Expo.
*
* Flow:
* 1. Permission anfragen (falls noch nicht entschieden)
* 2. ExponentPushToken[xxx] via getExpoPushTokenAsync() holen
* 3. Token an Backend POST /api/users/me/push-token senden
* 4. Bei Logout: DELETE /api/users/me/push-token?token=
*
* Wird einmal pro App-Start nach Login aufgerufen idempotent durch
* upsert im Backend.
*/
import { useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { apiFetch } from '../lib/api';
import { getDeviceId } from '../lib/deviceId';
const lastRegisteredToken: { current: string | null } = { current: null };
export async function registerPushTokenWithBackend(): Promise<string | null> {
// Simulator/Emulator support: Expo Push funktioniert auf physical devices only
// — Simulator gibt Permission denied. Wir loggen aber crashen nicht.
if (!Device.isDevice) {
if (__DEV__) console.log('[push] skipped (simulator)');
return null;
}
try {
// 1) Permission
const { status: existing } = await Notifications.getPermissionsAsync();
let status = existing;
if (existing !== 'granted') {
const { status: requested } = await Notifications.requestPermissionsAsync();
status = requested;
}
if (status !== 'granted') {
if (__DEV__) console.log('[push] permission denied');
return null;
}
// 2) Android-Channel (muss vor getExpoPushTokenAsync existieren)
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('chat', {
name: 'Chat-Nachrichten',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#007AFF',
sound: 'default',
});
}
// 3) Token holen
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
console.warn('[push] EAS projectId missing — token cannot be issued');
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
const token = tokenData.data;
if (!token) return null;
// 4) Idempotenz-Skip: wenn schon registriert in dieser Session, nicht nochmal
if (lastRegisteredToken.current === token) return token;
// 5) Senden an Backend
const deviceId = await getDeviceId();
await apiFetch('/api/users/me/push-token', {
method: 'POST',
body: {
token,
platform: Platform.OS as 'ios' | 'android',
deviceId,
},
});
lastRegisteredToken.current = token;
if (__DEV__) console.log('[push] token registered:', token.slice(0, 30) + '…');
return token;
} catch (err) {
console.warn('[push] registration failed:', err);
return null;
}
}
export async function unregisterPushTokenFromBackend(): Promise<void> {
const token = lastRegisteredToken.current;
if (!token) return;
try {
await apiFetch(
`/api/users/me/push-token?token=${encodeURIComponent(token)}`,
{ method: 'DELETE' },
);
lastRegisteredToken.current = null;
} catch (err) {
console.warn('[push] unregister failed:', err);
}
}
/**
* Hook: registriert Push-Token sobald User authenticated ist.
* Verwendet in app/_layout.tsx nach Auth-Init.
*/
export function usePushTokenRegistration(userId: string | null | undefined) {
const lastUserId = useRef<string | null>(null);
useEffect(() => {
if (!userId || userId === lastUserId.current) return;
lastUserId.current = userId;
void registerPushTokenWithBackend();
}, [userId]);
}

View File

@ -17,6 +17,7 @@
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@supabase/supabase-js": "^2.39.7",
"expo-server-sdk": "^6.1.0",
"franc": "^6.2.0",
"groq-sdk": "^0.7.0",
"imapflow": "^1.2.18",

View File

@ -0,0 +1,38 @@
-- PushToken — Expo-Push-Tokens pro User-Device.
--
-- Ein User kann mehrere Geräte (iPhone + Android-Tablet etc.) haben, jedes mit eigenem
-- ExponentPushToken[xxx]. Token ist unique (kommt von Expo, nicht von uns).
--
-- Genutzt von:
-- - POST /api/users/me/push-token (Client registriert Token nach permission grant)
-- - DELETE /api/users/me/push-token (Client deregistriert bei Logout/permission revoke)
-- - server/services/push.ts sendChatPush() (Backend triggert Push nach Chat-Message)
--
-- DSGVO: bei Profile-Delete (Art-17) kaskadiert via FK ON DELETE CASCADE → alle Tokens gelöscht.
CREATE TABLE IF NOT EXISTS "rebreak"."push_tokens" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL, -- 'ios' | 'android'
"device_id" TEXT, -- optional: Capacitor/Expo persistent UUID
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"last_used_at" TIMESTAMPTZ,
CONSTRAINT "push_tokens_pkey" PRIMARY KEY ("id"),
CONSTRAINT "push_tokens_token_key" UNIQUE ("token"),
CONSTRAINT "push_tokens_user_id_fkey" FOREIGN KEY ("user_id")
REFERENCES "rebreak"."profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "push_tokens_user_id_idx" ON "rebreak"."push_tokens"("user_id");
CREATE INDEX IF NOT EXISTS "push_tokens_enabled_idx" ON "rebreak"."push_tokens"("enabled") WHERE "enabled" = true;
-- ─── Profile.chat_push_enabled ─────────────────────────────────────────────
-- Per-User Opt-out für Chat-Push (Default ON). Granularer als Token-Enabled
-- weil hier User-Präferenz, nicht Device-State. Beide werden im Send-Pfad
-- berücksichtigt (UND-Verknüpfung).
ALTER TABLE "rebreak"."profiles"
ADD COLUMN IF NOT EXISTS "chat_push_enabled" BOOLEAN NOT NULL DEFAULT true;

View File

@ -114,6 +114,11 @@ model Profile {
mdmManaged Boolean @default(false) @map("mdm_managed")
mdmDetectedAt DateTime? @map("mdm_detected_at")
// ─── Push-Notifications (Migration 20260530) ──────────────────────────
// Per-User Opt-out für Chat-Push (DM + Room). Default ON. Token-spezifischer
// Disable (z.B. nach Permission-Revoke) wird in PushToken.enabled gesetzt.
chatPushEnabled Boolean @default(true) @map("chat_push_enabled")
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
@ -125,6 +130,7 @@ model Profile {
communityPosts CommunityPost[]
communityReplies CommunityReply[]
domainSubmissions DomainSubmission[]
pushTokens PushToken[]
@@index([deletedAt])
@@index([plan])
@ -132,6 +138,29 @@ model Profile {
@@schema("rebreak")
}
// ─── Push-Tokens (Expo) ──────────────────────────────────────────────────────
//
// Ein User kann mehrere Geräte (iOS + Android etc.) haben, jedes mit eigenem
// ExponentPushToken[xxx]. Token ist von Expo serverseitig unique.
// Genutzt von server/services/push.ts sendChatPush().
model PushToken {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
token String @unique
platform String // "ios" | "android"
deviceId String? @map("device_id")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
lastUsedAt DateTime? @map("last_used_at")
profile Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("push_tokens")
@@schema("rebreak")
}
// ─── DiGA-Codes (Rezept-Einlösung für Krankenkassen-Pfad) ─────────────────────
//
// Codes werden vom Backend ausgegeben (später per Krankenkassen-API erstellt

View File

@ -0,0 +1,29 @@
/**
* DELETE /api/users/me/push-token?token=ExponentPushToken[xxx]
*
* Client deregistriert Token (Logout, Permission-Revoke, App-Uninstall-Cleanup).
* Wir setzen `enabled = false` statt zu löschen Audit-Trail bleibt erhalten.
*/
import { requireUser } from "../../../utils/auth";
import { usePrisma } from "../../../utils/prisma";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const query = getQuery(event);
const token = typeof query.token === "string" ? query.token : "";
if (!token) {
throw createError({
statusCode: 400,
data: { error: "MISSING_TOKEN" },
});
}
const db = usePrisma();
await db.pushToken.updateMany({
where: { token, userId: user.id },
data: { enabled: false },
});
return { success: true, data: { ok: true } };
});

View File

@ -0,0 +1,54 @@
/**
* POST /api/users/me/push-token
*
* Client (Expo) ruft das nach `getExpoPushTokenAsync()` auf, um seinen Token
* im Backend zu hinterlegen. Idempotent: bei existierendem Token wird nur
* lastUsedAt + enabled aktualisiert.
*
* Body: { token: string, platform: "ios" | "android", deviceId?: string }
*/
import { requireUser } from "../../../utils/auth";
import { usePrisma } from "../../../utils/prisma";
import { z } from "zod";
const Body = z.object({
token: z.string().min(10).max(200), // ExponentPushToken[xxx]
platform: z.enum(["ios", "android"]),
deviceId: z.string().max(120).optional(),
});
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const raw = await readBody(event).catch(() => ({}));
const parsed = Body.safeParse(raw);
if (!parsed.success) {
throw createError({
statusCode: 400,
data: { error: "INVALID_BODY", detail: parsed.error.flatten() },
});
}
const { token, platform, deviceId } = parsed.data;
const db = usePrisma();
await db.pushToken.upsert({
where: { token },
create: {
userId: user.id,
token,
platform,
deviceId: deviceId ?? null,
enabled: true,
lastUsedAt: new Date(),
},
update: {
userId: user.id, // Token könnte das Device gewechselt haben
platform,
deviceId: deviceId ?? null,
enabled: true,
lastUsedAt: new Date(),
},
});
return { success: true, data: { ok: true } };
});

View File

@ -269,6 +269,38 @@ export async function createRoomMessage(data: {
where: { id: data.roomId },
data: { updatedAt: new Date() },
});
// Push-Notifications an alle Room-Member außer dem Sender selbst (fire-and-forget)
void (async () => {
const { sendChatPush, getDisplayName, truncatePreview } = await import(
"../services/push"
);
const members = await db.chatRoomMember.findMany({
where: { roomId: data.roomId, userId: { not: data.userId } },
select: { userId: true },
});
if (members.length === 0) return;
const senderName = await getDisplayName(data.userId);
const room = await db.chatRoom.findUnique({
where: { id: data.roomId },
select: { name: true },
});
const title = room?.name ? `${senderName} · ${room.name}` : senderName;
const preview = truncatePreview(
data.content || (data.attachmentUrl ? "📎 Anhang" : ""),
);
await Promise.all(
members.map((m) =>
sendChatPush({
receiverId: m.userId,
senderName: title,
preview,
data: { type: "room", targetId: data.roomId, messageId: msg.id },
}),
),
);
})();
return msg;
}

View File

@ -34,7 +34,7 @@ export async function sendDirectMessage(
},
) {
const db = usePrisma();
return db.directMessage.create({
const msg = await db.directMessage.create({
data: {
senderId,
receiverId,
@ -58,6 +58,22 @@ export async function sendDirectMessage(
},
},
});
// Push-Notification (fire-and-forget — blockt Response nicht)
void (async () => {
const { sendChatPush, getDisplayName, truncatePreview } = await import(
"../services/push"
);
const senderName = await getDisplayName(senderId);
await sendChatPush({
receiverId,
senderName,
preview: truncatePreview(content || (opts?.attachmentUrl ? "📎 Anhang" : "")),
data: { type: "dm", targetId: senderId, messageId: msg.id },
});
})();
return msg;
}
export async function getDmHistory(

View File

@ -0,0 +1,128 @@
/**
* Push-Notifications via Expo Server SDK.
*
* Verwendet von:
* - backend/server/db/chat.ts sendDirectMessage() triggert nach Insert
* - backend/server/db/chat-rooms.ts createRoomMessage() triggert nach Insert
*
* Versand ist fire-and-forget (kein Block des HTTP-Response): Errors werden geloggt
* aber nicht propagiert, damit Chat-Send nicht failed wenn Expo-Server down ist.
*
* Token-Cleanup: Bei DeviceNotRegistered Receipts werden Tokens automatisch
* disabled (PushToken.enabled = false) Re-Enable nur durch Re-Registrierung
* vom Client.
*/
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
import { usePrisma } from "../utils/prisma";
const expo = new Expo();
export interface ChatPushPayload {
/** Empfänger-User-ID (kann mehrere Tokens haben) */
receiverId: string;
/** Sender-Display-Name (für Titel "Max sendete: …") */
senderName: string;
/** Nachrichten-Vorschau (max ~100 Zeichen) */
preview: string;
/** Deep-Link-Daten — Client navigiert beim Tap */
data: {
type: "dm" | "room";
/** Bei DM: senderId, bei Room: roomId */
targetId: string;
messageId: string;
};
}
export async function sendChatPush(payload: ChatPushPayload): Promise<void> {
try {
const db = usePrisma();
// 1) Profile-Opt-out prüfen
const profile = await db.profile.findUnique({
where: { id: payload.receiverId },
select: { chatPushEnabled: true, deletedAt: true },
});
if (!profile || profile.deletedAt || !profile.chatPushEnabled) {
return;
}
// 2) Alle aktiven Tokens des Receivers
const tokens = await db.pushToken.findMany({
where: { userId: payload.receiverId, enabled: true },
select: { id: true, token: true },
});
if (tokens.length === 0) return;
// 3) Messages bauen (nur valide Expo-Tokens)
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (!Expo.isExpoPushToken(t.token)) {
// Ungültiger Token-Format → disablen
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })
.catch(() => {});
continue;
}
messages.push({
to: t.token,
sound: "default",
title: payload.senderName,
body: payload.preview,
data: payload.data,
// Channel-ID für Android — wird in app.config.ts/native registriert
channelId: "chat",
});
validTokenIds.push(t.id);
}
if (messages.length === 0) return;
// 4) Senden in Chunks (Expo akzeptiert max 100 pro Request)
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (err) {
console.error("[push] chunk send failed:", err);
}
}
// 5) lastUsedAt bumpen (best-effort)
await db.pushToken
.updateMany({
where: { id: { in: validTokenIds } },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
// Hinweis: Receipt-Polling (DeviceNotRegistered → token disablen) wird in
// einem separaten Cron-Job gemacht (TODO: scripts/push-receipts-cleanup.ts).
// Für MVP: Tokens bleiben aktiv bis sie explizit vom Client deregistriert
// werden oder Expo dauerhaft DeviceNotRegistered meldet.
} catch (err) {
console.error("[push] sendChatPush failed:", err);
}
}
/**
* Helper für Username-Lookup (für Push-Titel).
* Fällt auf "Jemand" zurück wenn kein nickname/username gesetzt.
*/
export async function getDisplayName(userId: string): Promise<string> {
const db = usePrisma();
const p = await db.profile.findUnique({
where: { id: userId },
select: { nickname: true, username: true },
});
return p?.nickname || p?.username || "Jemand";
}
/**
* Truncate für Push-Body (Expo hat 4kb hard limit, aber UI zeigt eh nur ~2 Zeilen).
*/
export function truncatePreview(text: string, max = 100): string {
if (text.length <= max) return text;
return text.slice(0, max - 1) + "…";
}

1155
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff