feat: implement real-time database synchronization with SSE and update backend modules
Test Suite / backend-pr-quick (24.15.0) (push) Has been cancelled
Test Suite / backend-full (24.15.0) (push) Has been cancelled
Test Suite / flutter-quality (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-12 16:57:05 +02:00
parent 2dda34d4d2
commit 98ee8a3ad6
11 changed files with 400 additions and 7 deletions
+63 -3
View File
@@ -7,7 +7,8 @@ on:
branches: [ main, develop ] branches: [ main, develop ]
jobs: jobs:
test: backend-pr-quick:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@@ -28,14 +29,64 @@ jobs:
working-directory: ./backend working-directory: ./backend
run: npm ci run: npm ci
- name: Prisma schema validate
working-directory: ./backend
run: npx prisma validate --schema prisma/schema.prisma
- name: Generate Prisma Client - name: Generate Prisma Client
working-directory: ./backend working-directory: ./backend
run: npm run prisma:generate run: npm run prisma:generate
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Build NestJS app
working-directory: ./backend
run: npm run build
backend-full:
if: github.event_name == 'push'
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24.15.0]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies (backend)
working-directory: ./backend
run: npm ci
- name: Prisma schema validate - name: Prisma schema validate
working-directory: ./backend working-directory: ./backend
run: npx prisma validate --schema prisma/schema.prisma run: npx prisma validate --schema prisma/schema.prisma
- name: Generate Prisma Client
working-directory: ./backend
run: npm run prisma:generate
- name: Verify generated Prisma client is typed
working-directory: ./backend
run: |
if ! grep -q "export \* from '.prisma/client/default'" node_modules/@prisma/client/index.d.ts; then
echo "Prisma client export is unexpected";
exit 1;
fi
- name: Dependency audit (high+critical) - name: Dependency audit (high+critical)
working-directory: ./backend working-directory: ./backend
run: npm audit --audit-level=high run: npm audit --audit-level=high
@@ -47,7 +98,6 @@ jobs:
- name: Build NestJS app - name: Build NestJS app
working-directory: ./backend working-directory: ./backend
run: npm run build run: npm run build
continue-on-error: true
flutter-quality: flutter-quality:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -65,6 +115,16 @@ jobs:
- 'flutter/**' - 'flutter/**'
- '.github/workflows/test.yml' - '.github/workflows/test.yml'
- name: Set Flutter test mode
if: steps.filter.outputs.flutter == 'true'
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "FLUTTER_TEST_CMD=flutter test --reporter=compact" >> "$GITHUB_ENV"
else
echo "FLUTTER_TEST_CMD=flutter test" >> "$GITHUB_ENV"
fi
- name: Setup Flutter - name: Setup Flutter
if: steps.filter.outputs.flutter == 'true' if: steps.filter.outputs.flutter == 'true'
uses: subosito/flutter-action@v2 uses: subosito/flutter-action@v2
@@ -84,4 +144,4 @@ jobs:
- name: Run Flutter tests - name: Run Flutter tests
if: steps.filter.outputs.flutter == 'true' if: steps.filter.outputs.flutter == 'true'
working-directory: ./flutter working-directory: ./flutter
run: flutter test run: ${{ env.FLUTTER_TEST_CMD }}
+2
View File
@@ -16,6 +16,7 @@ import { UsersModule } from './users/users.module';
import { UserProductsModule } from './user-products/user-products.module'; import { UserProductsModule } from './user-products/user-products.module';
import { CategoriesModule } from './categories/categories.module'; import { CategoriesModule } from './categories/categories.module';
import { AiModule } from './ai/ai.module'; import { AiModule } from './ai/ai.module';
import { RealtimeModule } from './realtime/realtime.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard'; import { RolesGuard } from './auth/roles.guard';
@@ -44,6 +45,7 @@ import { RolesGuard } from './auth/roles.guard';
UserProductsModule, UserProductsModule,
CategoriesModule, CategoriesModule,
AiModule, AiModule,
RealtimeModule,
], ],
providers: [ providers: [
{ {
+24 -2
View File
@@ -1,5 +1,6 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { Prisma, PrismaClient } from '@prisma/client';
import { RealtimeEventsService } from '../realtime/realtime-events.service';
@Injectable() @Injectable()
export class PrismaService export class PrismaService
@@ -7,9 +8,30 @@ export class PrismaService
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
private readonly logger = new Logger(PrismaService.name); private readonly logger = new Logger(PrismaService.name);
private readonly writeActions = new Set<string>([
'create',
'update',
'upsert',
'delete',
'createMany',
'updateMany',
'deleteMany',
]);
constructor() { constructor(private readonly realtimeEvents: RealtimeEventsService) {
super(); super();
const realtimeMiddleware: Prisma.Middleware = async (params, next) => {
const result = await next(params);
if (params.model && this.writeActions.has(params.action)) {
this.realtimeEvents.notifyDatabaseWrite();
}
return result;
};
this.$use(realtimeMiddleware);
} }
async onModuleInit() { async onModuleInit() {
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Subject } from 'rxjs';
export interface DbChangeEvent {
timestamp: string;
}
@Injectable()
export class RealtimeEventsService {
private readonly subject = new Subject<DbChangeEvent>();
private flushTimer: NodeJS.Timeout | null = null;
private hasPendingChanges = false;
readonly events$ = this.subject.asObservable();
notifyDatabaseWrite(): void {
this.hasPendingChanges = true;
if (this.flushTimer) return;
// Coalesce burst writes into one SSE event to reduce client/server load.
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
if (!this.hasPendingChanges) return;
this.hasPendingChanges = false;
this.subject.next({ timestamp: new Date().toISOString() });
}, 400);
}
}
@@ -0,0 +1,28 @@
import { Controller, MessageEvent, Sse } from '@nestjs/common';
import { interval, map, merge, Observable } from 'rxjs';
import { RealtimeEventsService } from './realtime-events.service';
@Controller('events')
export class RealtimeController {
constructor(private readonly realtimeEvents: RealtimeEventsService) {}
@Sse('stream')
stream(): Observable<MessageEvent> {
const changes$ = this.realtimeEvents.events$.pipe(
map((event) => ({
type: 'db-change',
data: event,
})),
);
// Keeps connections alive through proxies and gives clients liveness signal.
const heartbeat$ = interval(20_000).pipe(
map(() => ({
type: 'heartbeat',
data: { timestamp: new Date().toISOString() },
})),
);
return merge(changes$, heartbeat$);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { RealtimeController } from './realtime.controller';
import { RealtimeEventsService } from './realtime-events.service';
@Global()
@Module({
controllers: [RealtimeController],
providers: [RealtimeEventsService],
exports: [RealtimeEventsService],
})
export class RealtimeModule {}
@@ -0,0 +1,174 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../features/auth/data/auth_providers.dart';
import '../../features/inventory/data/inventory_providers.dart';
import '../../features/meal_plan/data/meal_plan_providers.dart';
import '../../features/pantry/data/pantry_providers.dart';
import '../../features/recipes/data/recipe_providers.dart';
import '../api/api_providers.dart';
class RealtimeDbEvent {
final String type;
final DateTime? timestamp;
const RealtimeDbEvent({
required this.type,
this.timestamp,
});
factory RealtimeDbEvent.fromSse({
required String type,
required String data,
}) {
final payload = jsonDecode(data) as Map<String, dynamic>;
final timestampRaw = payload['timestamp']?.toString();
return RealtimeDbEvent(
type: type,
timestamp: timestampRaw == null ? null : DateTime.tryParse(timestampRaw),
);
}
}
class RealtimeSseClient {
final String baseUrl;
final http.Client _client;
RealtimeSseClient({required this.baseUrl, http.Client? client})
: _client = client ?? http.Client();
void close() {
_client.close();
}
Stream<RealtimeDbEvent> connect({required String token}) async* {
while (true) {
try {
final request = http.Request('GET', Uri.parse('$baseUrl/events/stream'));
request.headers['Accept'] = 'text/event-stream';
request.headers['Authorization'] = 'Bearer $token';
final response = await _client.send(request);
if (response.statusCode == 401 || response.statusCode == 403) {
return;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('SSE connection failed: HTTP ${response.statusCode}');
}
String currentType = 'message';
final dataLines = <String>[];
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.isEmpty) {
if (dataLines.isNotEmpty) {
final data = dataLines.join('\n');
if (data.trim().isNotEmpty) {
yield RealtimeDbEvent.fromSse(type: currentType, data: data);
}
}
currentType = 'message';
dataLines.clear();
continue;
}
if (line.startsWith(':')) {
continue;
}
if (line.startsWith('event:')) {
currentType = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.add(line.substring(5).trimLeft());
}
}
} catch (_) {
// Reconnect loop keeps stream alive across transient errors.
}
await Future<void>.delayed(const Duration(seconds: 2));
}
}
}
class _RealtimeTickNotifier extends Notifier<int> {
@override
int build() => 0;
void bump() {
state = state + 1;
}
}
final realtimeRefreshTickProvider =
NotifierProvider<_RealtimeTickNotifier, int>(_RealtimeTickNotifier.new);
final realtimeDbEventsProvider = StreamProvider<RealtimeDbEvent>((ref) async* {
final token = await ref.watch(authStateProvider.future);
if (token == null || token.isEmpty) {
return;
}
final apiClient = ref.watch(apiClientProvider);
final client = RealtimeSseClient(baseUrl: apiClient.baseUrl);
ref.onDispose(client.close);
yield* client.connect(token: token);
});
class GlobalRealtimeSync extends ConsumerStatefulWidget {
final Widget child;
const GlobalRealtimeSync({super.key, required this.child});
@override
ConsumerState<GlobalRealtimeSync> createState() => _GlobalRealtimeSyncState();
}
class _GlobalRealtimeSyncState extends ConsumerState<GlobalRealtimeSync> {
ProviderSubscription<AsyncValue<RealtimeDbEvent>>? _subscription;
Timer? _coalesceTimer;
void _scheduleRefresh() {
_coalesceTimer?.cancel();
_coalesceTimer = Timer(const Duration(milliseconds: 500), () {
if (!mounted) return;
ref.invalidate(inventoryProvider);
ref.invalidate(pantryProvider);
ref.invalidate(recipesProvider);
ref.invalidate(mealPlanDashboardProvider);
ref.read(realtimeRefreshTickProvider.notifier).bump();
});
}
@override
void initState() {
super.initState();
_subscription = ref.listenManual(realtimeDbEventsProvider, (_, next) {
next.whenData((event) {
if (event.type != 'db-change') return;
_scheduleRefresh();
});
});
}
@override
void dispose() {
_coalesceTimer?.cancel();
_subscription?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import 'admin_ai_panel.dart'; import 'admin_ai_panel.dart';
import 'admin_aliases_panel.dart'; import 'admin_aliases_panel.dart';
import 'admin_inventory_panel.dart'; import 'admin_inventory_panel.dart';
@@ -38,6 +40,32 @@ class AdminDatabasePanel extends ConsumerStatefulWidget {
class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> { class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
_DatabaseTab _activeTab = _DatabaseTab.inventory; _DatabaseTab _activeTab = _DatabaseTab.inventory;
bool _isRefreshingCategories = false; bool _isRefreshingCategories = false;
int _panelRefreshVersion = 0;
ProviderSubscription<int>? _realtimeTickSubscription;
Timer? _realtimeDebounce;
@override
void initState() {
super.initState();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_realtimeDebounce?.cancel();
_realtimeDebounce = Timer(const Duration(milliseconds: 600), () {
if (!mounted) return;
setState(() => _panelRefreshVersion++);
});
},
);
}
@override
void dispose() {
_realtimeDebounce?.cancel();
_realtimeTickSubscription?.close();
super.dispose();
}
List<_DatabaseTabConfig> get _tabConfigs => [ List<_DatabaseTabConfig> get _tabConfigs => [
_DatabaseTabConfig( _DatabaseTabConfig(
@@ -153,7 +181,12 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
children: [ children: [
header, header,
const SizedBox(height: 12), const SizedBox(height: 12),
Expanded(child: currentTab.panel), Expanded(
child: KeyedSubtree(
key: ValueKey('admin-db-${_activeTab.name}-$_panelRefreshVersion'),
child: currentTab.panel,
),
),
], ],
), ),
); );
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/realtime/realtime_sync.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/user_admin.dart'; import '../domain/user_admin.dart';
@@ -28,6 +30,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
bool _filterPremiumOnly = false; bool _filterPremiumOnly = false;
bool _filterSharingOffOnly = false; bool _filterSharingOffOnly = false;
List<UserAdmin> _users = []; List<UserAdmin> _users = [];
ProviderSubscription<int>? _realtimeTickSubscription;
Timer? _realtimeDebounce;
String _sortLabel(_UserSort sort) => switch (sort) { String _sortLabel(_UserSort sort) => switch (sort) {
_UserSort.newest => 'Nyast', _UserSort.newest => 'Nyast',
@@ -71,11 +75,24 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
void initState() { void initState() {
super.initState(); super.initState();
_searchCtrl = TextEditingController(); _searchCtrl = TextEditingController();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_realtimeDebounce?.cancel();
_realtimeDebounce = Timer(const Duration(milliseconds: 600), () {
if (!mounted) return;
_load();
});
},
);
_load(); _load();
} }
@override @override
void dispose() { void dispose() {
_realtimeDebounce?.cancel();
_realtimeTickSubscription?.close();
_searchCtrl.dispose(); _searchCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/realtime/realtime_sync.dart';
import '../../admin/data/admin_repository.dart'; import '../../admin/data/admin_repository.dart';
import '../../admin/domain/receipt_alias.dart'; import '../../admin/domain/receipt_alias.dart';
@@ -16,13 +17,27 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
List<ReceiptAlias> _aliases = []; List<ReceiptAlias> _aliases = [];
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
ProviderSubscription<int>? _realtimeTickSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_realtimeTickSubscription = ref.listenManual<int>(
realtimeRefreshTickProvider,
(_, __) {
if (!mounted) return;
_load();
},
);
_load(); _load();
} }
@override
void dispose() {
_realtimeTickSubscription?.close();
super.dispose();
}
Future<void> _load() async { Future<void> _load() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
+4 -1
View File
@@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/l10n/l10n.dart'; import 'core/l10n/l10n.dart';
import 'core/realtime/realtime_sync.dart';
import 'core/router/app_router.dart'; import 'core/router/app_router.dart';
void main() { void main() {
@@ -18,7 +19,9 @@ class RecipeApp extends ConsumerWidget {
return MaterialApp.router( return MaterialApp.router(
onGenerateTitle: (context) => context.l10n.appTitle, onGenerateTitle: (context) => context.l10n.appTitle,
builder: (context, child) { builder: (context, child) {
return SelectionArea(child: child ?? const SizedBox.shrink()); return GlobalRealtimeSync(
child: SelectionArea(child: child ?? const SizedBox.shrink()),
);
}, },
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),