diff --git a/catalyst_voices/apps/voices/lib/app/view/app.dart b/catalyst_voices/apps/voices/lib/app/view/app.dart index 025d70167c0f..10e97f7b19e7 100644 --- a/catalyst_voices/apps/voices/lib/app/view/app.dart +++ b/catalyst_voices/apps/voices/lib/app/view/app.dart @@ -47,9 +47,6 @@ class _AppState extends State { BlocProvider( create: (_) => Dependencies.instance.get(), ), - BlocProvider( - create: (_) => Dependencies.instance.get(), - ), BlocProvider( create: (_) => Dependencies.instance.get(), ), diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 1883a296e9e6..76e91790e5ac 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -113,7 +113,7 @@ final class Dependencies extends DependencyProvider { blockchainConfig: get().blockchain, ); }) - ..registerLazySingleton( + ..registerFactory( () => ProposalsCubit( get(), get(), diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart index 028451fe8874..46a2cbc843c7 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/proposals_page.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:catalyst_voices/common/error_handler.dart'; import 'package:catalyst_voices/common/signal_handler.dart'; +import 'package:catalyst_voices/dependency/dependencies.dart'; import 'package:catalyst_voices/pages/campaign_phase_aware/proposal_submission_phase_aware.dart'; import 'package:catalyst_voices/pages/proposals/widgets/proposals_content.dart'; import 'package:catalyst_voices/pages/proposals/widgets/proposals_header.dart'; @@ -17,7 +18,7 @@ import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; class ProposalsPage extends StatefulWidget { - final SignedDocumentRef? categoryId; + final String? categoryId; final ProposalsPageTab? tab; const ProposalsPage({ @@ -35,18 +36,28 @@ class _ProposalsPageState extends State TickerProviderStateMixin, ErrorHandlerStateMixin, SignalHandlerStateMixin { + late final _cubit = Dependencies.instance.get(); late VoicesTabController _tabController; late final PagingController _pagingController; late final StreamSubscription> _tabsSubscription; + @override + ProposalsCubit get errorEmitter => _cubit; + + @override + ProposalsCubit get signalEmitter => _cubit; + @override Widget build(BuildContext context) { - return ProposalSubmissionPhaseAware( - activeChild: HeaderAndContentLayout( - header: const ProposalsHeader(), - content: ProposalsContent( - tabController: _tabController, - pagingController: _pagingController, + return BlocProvider.value( + value: _cubit, + child: ProposalSubmissionPhaseAware( + activeChild: HeaderAndContentLayout( + header: const ProposalsHeader(), + content: ProposalsContent( + tabController: _tabController, + pagingController: _pagingController, + ), ), ), ); @@ -59,10 +70,9 @@ class _ProposalsPageState extends State final tab = widget.tab ?? ProposalsPageTab.total; if (widget.categoryId != oldWidget.categoryId || widget.tab != oldWidget.tab) { - context.read().changeFilters( - onlyMy: Optional(tab == ProposalsPageTab.my), - category: Optional(widget.categoryId), - type: tab.filter, + _cubit.changeFilters( + categoryId: Optional(widget.categoryId), + tab: Optional(tab), ); _doResetPagination(); @@ -75,6 +85,7 @@ class _ProposalsPageState extends State @override void dispose() { + unawaited(_cubit.close()); _tabController.dispose(); _pagingController.dispose(); unawaited(_tabsSubscription.cancel()); @@ -105,9 +116,8 @@ class _ProposalsPageState extends State void initState() { super.initState(); - final proposalsCubit = context.read(); final sessionCubit = context.read(); - final supportedTabs = _determineTabs(sessionCubit.state.isProposerUnlock, proposalsCubit.state); + final supportedTabs = _determineTabs(sessionCubit.state.isProposerUnlock, _cubit.state); final selectedTab = _determineTab(supportedTabs, widget.tab); _tabController = VoicesTabController( @@ -123,15 +133,13 @@ class _ProposalsPageState extends State _tabsSubscription = Rx.combineLatest2( sessionCubit.watchState().map((e) => e.isProposerUnlock), - proposalsCubit.watchState(), + _cubit.watchState(), _determineTabs, ).distinct().listen(_updateTabsIfNeeded); - proposalsCubit.init( - onlyMyProposals: selectedTab == ProposalsPageTab.my, - category: widget.categoryId, - type: selectedTab.filter, - order: const Alphabetical(), + _cubit.init( + categoryId: widget.categoryId, + tab: widget.tab ?? ProposalsPageTab.total, ); _pagingController @@ -167,7 +175,7 @@ class _ProposalsPageState extends State ProposalBrief? lastProposalId, ) async { final request = PageRequest(page: pageKey, size: pageSize); - await context.read().getProposals(request); + await _cubit.getProposals(request); } void _updateRoute({ @@ -175,7 +183,7 @@ class _ProposalsPageState extends State ProposalsPageTab? tab, }) { Router.neglect(context, () { - final effectiveCategoryId = categoryId.dataOr(widget.categoryId?.id); + final effectiveCategoryId = categoryId.dataOr(widget.categoryId); final effectiveTab = tab?.name ?? widget.tab?.name; ProposalsRoute( diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart index 17656049d741..eaacaa1dfe8c 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_pagination_tile.dart @@ -24,9 +24,11 @@ class ProposalsPaginationTile extends StatelessWidget { unawaited(route.push(context)); }, onFavoriteChanged: (isFavorite) { - context.read().onChangeFavoriteProposal( - proposal.selfRef, - isFavorite: isFavorite, + unawaited( + context.read().onChangeFavoriteProposal( + proposal.selfRef, + isFavorite: isFavorite, + ), ); }, ); diff --git a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart index 4b7a64b6eb02..36a5ce9e7de3 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposals/widgets/proposals_tabs.dart @@ -3,7 +3,6 @@ import 'package:catalyst_voices/widgets/tabbar/voices_tab_bar.dart'; import 'package:catalyst_voices/widgets/tabbar/voices_tab_controller.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; @@ -15,29 +14,6 @@ class ProposalsTabs extends StatelessWidget { required this.controller, }); - @override - Widget build(BuildContext context) { - return BlocSelector( - selector: (state) => state.count, - builder: (context, state) { - return _ProposalsTabs( - data: state, - controller: controller, - ); - }, - ); - } -} - -class _ProposalsTabs extends StatelessWidget { - final ProposalsCount data; - final VoicesTabController controller; - - const _ProposalsTabs({ - required this.data, - required this.controller, - }); - @override Widget build(BuildContext context) { return VoicesTabBar( @@ -51,13 +27,30 @@ class _ProposalsTabs extends StatelessWidget { VoicesTab( data: tab, key: tab.tabKey(), - child: VoicesTabText(tab.noOf(context, count: data.ofType(tab.filter))), + child: _TabText(key: ValueKey('${tab.name}Text'), tab: tab), ), ], ); } } +class _TabText extends StatelessWidget { + final ProposalsPageTab tab; + + const _TabText({ + required super.key, + required this.tab, + }); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.count[tab] ?? 0, + builder: (context, state) => VoicesTabText(tab.noOf(context, count: state)), + ); + } +} + extension on ProposalsPageTab { String noOf( BuildContext context, { diff --git a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart index 890e49a53837..eea4fbb4b124 100644 --- a/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart +++ b/catalyst_voices/apps/voices/lib/routes/routing/spaces_route.dart @@ -84,13 +84,10 @@ final class ProposalsRoute extends GoRouteData with FadePageTransitionMixin { @override Widget build(BuildContext context, GoRouterState state) { - final categoryId = this.categoryId; - final categoryRef = categoryId != null ? SignedDocumentRef(id: categoryId) : null; - final tab = ProposalsPageTab.values.asNameMap()[this.tab]; return ProposalsPage( - categoryId: categoryRef, + categoryId: categoryId, tab: tab, ); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart index bca577d1b171..ba7ff4b45bc4 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/discovery/discovery_cubit.dart @@ -161,7 +161,7 @@ class DiscoveryCubit extends Cubit with BlocErrorEmitterMixin { unawaited(_proposalsV2Sub?.cancel()); _proposalsV2Sub = _proposalService - .watchProposalsBriefPage( + .watchProposalsBriefPageV2( request: const PageRequest(page: 0, size: _maxRecentProposalsCount), ) .map((page) => page.items) diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart index b40e8908d102..f4a0cb30b79d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit.dart @@ -6,7 +6,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:catalyst_voices_services/catalyst_voices_services.dart'; import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; -import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; const _recentProposalsMaxAge = Duration(hours: 72); final _logger = Logger('ProposalsCubit'); @@ -23,11 +23,15 @@ final class ProposalsCubit extends Cubit final CampaignService _campaignService; final ProposalService _proposalService; - ProposalsCubitCache _cache = const ProposalsCubitCache(); + ProposalsCubitCache _cache = ProposalsCubitCache( + filters: ProposalsFiltersV2(campaign: ProposalsCampaignFilters.active()), + ); StreamSubscription? _activeAccountIdSub; - StreamSubscription>? _favoritesProposalsIdsSub; - StreamSubscription? _proposalsCountSub; + StreamSubscription>? _proposalsCountSub; + StreamSubscription>? _proposalsPageSub; + + Completer? _proposalsRequestCompleter; ProposalsCubit( this._userService, @@ -35,70 +39,91 @@ final class ProposalsCubit extends Cubit this._proposalService, ) : super(const ProposalsState(recentProposalsMaxAge: _recentProposalsMaxAge)) { _resetCache(); + _rebuildProposalsCountSubs(); _activeAccountIdSub = _userService.watchUser .map((event) => event.activeAccount?.catalystId) .distinct() .listen(_handleActiveAccountIdChange); + } + + Future get _campaign async { + final cachedCampaign = _cache.campaign; + if (cachedCampaign != null) { + return cachedCampaign; + } - _favoritesProposalsIdsSub = _proposalService - .watchFavoritesProposalsIds() - .distinct(listEquals) - .listen(_handleFavoriteProposalsIds); + final campaign = await _campaignService.getActiveCampaign(); + _cache = _cache.copyWith(campaign: Optional(campaign)); + return campaign; } void changeFilters({ - Optional? author, - Optional? onlyMy, - Optional? category, - ProposalsFilterType? type, + Optional? categoryId, + Optional? tab, Optional? searchQuery, bool? isRecentEnabled, bool resetProposals = false, }) { + _cache = _cache.copyWith(tab: tab); + emit(state.copyWith(isOrderEnabled: _cache.tab == ProposalsPageTab.total)); + + final status = switch (_cache.tab) { + ProposalsPageTab.drafts => ProposalStatusFilter.draft, + ProposalsPageTab.finals => ProposalStatusFilter.aFinal, + ProposalsPageTab.total || ProposalsPageTab.favorites || ProposalsPageTab.my || null => null, + }; + final filters = _cache.filters.copyWith( - type: type, - author: author, - onlyAuthor: onlyMy, - category: category, + status: Optional(status), + isFavorite: _cache.tab == ProposalsPageTab.favorites + ? const Optional(true) + : const Optional.empty(), + author: Optional(_cache.tab == ProposalsPageTab.my ? _cache.activeAccountId : null), + categoryId: categoryId, searchQuery: searchQuery, - maxAge: isRecentEnabled != null + latestUpdate: isRecentEnabled != null ? Optional(isRecentEnabled ? _recentProposalsMaxAge : null) : null, + campaign: Optional(ProposalsCampaignFilters.active()), ); if (_cache.filters == filters) { return; } + final statusChanged = _cache.filters.status != filters.status; + final categoryChanged = _cache.filters.categoryId != filters.categoryId; + final searchQueryChanged = _cache.filters.searchQuery != filters.searchQuery; + final latestUpdateChanged = _cache.filters.latestUpdate != filters.latestUpdate; + final campaignChanged = _cache.filters.campaign != filters.campaign; + + final shouldRebuildCountSubs = + categoryChanged || searchQueryChanged || latestUpdateChanged || campaignChanged; + _cache = _cache.copyWith(filters: filters); emit( state.copyWith( - isOrderEnabled: _cache.filters.type == ProposalsFilterType.total, - isRecentProposalsEnabled: _cache.filters.maxAge != null, + isRecentProposalsEnabled: _cache.filters.latestUpdate != null, ), ); - if (category != null) _rebuildCategories(); - if (type != null) _rebuildOrder(); - - _watchProposalsCount(filters: filters.toCountFilters()); - - if (resetProposals) { - emitSignal(const ResetPaginationProposalsSignal()); - } + if (categoryId != null) _rebuildCategories(); + if (statusChanged) _rebuildOrder(); + if (shouldRebuildCountSubs) _rebuildProposalsCountSubs(); + if (resetProposals) emitSignal(const ResetPaginationProposalsSignal()); } void changeOrder( ProposalsOrder? order, { bool resetProposals = false, }) { - if (_cache.selectedOrder == order) { + if (_cache.order == order) { return; } - _cache = _cache.copyWith(selectedOrder: Optional(order)); + _cache = _cache.copyWith(order: Optional(order)); _rebuildOrder(); @@ -116,81 +141,70 @@ final class ProposalsCubit extends Cubit await _activeAccountIdSub?.cancel(); _activeAccountIdSub = null; - await _favoritesProposalsIdsSub?.cancel(); - _favoritesProposalsIdsSub = null; - await _proposalsCountSub?.cancel(); _proposalsCountSub = null; + await _proposalsPageSub?.cancel(); + _proposalsPageSub = null; + + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = null; + return super.close(); } Future getProposals(PageRequest request) async { - try { - if (_cache.campaign == null) { - final campaign = await _campaignService.getActiveCampaign(); - _cache = _cache.copyWith(campaign: Optional(campaign)); - } - - final filters = _cache.filters; - final order = _resolveEffectiveOrder(); - - _logger.finer('Proposals request[$request], filters[$filters], order[$order]'); + final filters = _cache.filters; + final order = _resolveEffectiveOrder(); - final page = await _proposalService.getProposalsPage( - request: request, - filters: filters, - order: order, - ); + if (_proposalsRequestCompleter != null && !_proposalsRequestCompleter!.isCompleted) { + _proposalsRequestCompleter!.complete(); + } + _proposalsRequestCompleter = Completer(); - _cache = _cache.copyWith(page: Optional(page)); + await _proposalsPageSub?.cancel(); + _proposalsPageSub = _proposalService + .watchProposalsBriefPageV2(request: request, order: order, filters: filters) + .map((page) => page.map(ProposalBrief.fromData)) + .distinct() + .listen(_handleProposalsChange); - _emitCachedProposalsPage(); - } catch (error, stackTrace) { - _logger.severe('Failed loading page $request', error, stackTrace); - } + await _proposalsRequestCompleter?.future; } void init({ - required bool onlyMyProposals, - required SignedDocumentRef? category, - required ProposalsFilterType type, - required ProposalsOrder order, + String? categoryId, + ProposalsPageTab? tab, + ProposalsOrder order = const Alphabetical(), }) { _resetCache(); _rebuildOrder(); unawaited(_loadCampaignCategories()); - changeFilters(onlyMy: Optional(onlyMyProposals), category: Optional(category), type: type); + changeFilters(categoryId: Optional(categoryId), tab: Optional(tab)); changeOrder(order); } /// Changes the favorite status of the proposal with [ref]. - void onChangeFavoriteProposal( + Future onChangeFavoriteProposal( DocumentRef ref, { required bool isFavorite, - }) { - final favoritesIds = List.of(state.favoritesIds); - - if (isFavorite) { - favoritesIds.add(ref.id); - } else { - favoritesIds.removeWhere((element) => element == ref.id); - } - - emit(state.copyWith(favoritesIds: favoritesIds)); + }) async { + _updateFavoriteProposalLocally(ref, isFavorite); - if (!isFavorite && _cache.filters.type.isFavorite) { - final page = _cache.page; - if (page != null) { - final proposals = page.items.where((element) => element.proposal.selfRef != ref).toList(); - final updatedPage = page.copyWithItems(proposals); - _cache = _cache.copyWith(page: Optional(updatedPage)); - _emitCachedProposalsPage(); + try { + if (isFavorite) { + await _proposalService.addFavoriteProposal(ref: ref); + } else { + await _proposalService.removeFavoriteProposal(ref: ref); } - } + } catch (error, stack) { + _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - unawaited(_updateFavoriteProposal(ref, isFavorite: isFavorite)); + emitError(LocalizedException.create(error)); + } } void updateSearchQuery(String query) { @@ -205,49 +219,67 @@ final class ProposalsCubit extends Cubit emit(state.copyWith(hasSearchQuery: !asOptional.isEmpty)); } - void _emitCachedProposalsPage() { - final campaign = _cache.campaign; - final page = _cache.page; - final showComments = campaign?.supportsComments ?? false; - - if (campaign == null || page == null) { - return; - } - - final mappedPage = page.map( - // TODO(damian-molinski): refactor page to return ProposalWithContext instead. - (e) => ProposalBrief.fromProposal( - e.proposal, - isFavorite: state.favoritesIds.contains(e.proposal.selfRef.id), - categoryName: campaign.categories - .firstWhere((element) => element.selfRef == e.proposal.categoryRef) - .formattedCategoryName, - showComments: showComments, + ProposalsFiltersV2 _buildProposalsCountFilters(ProposalsPageTab tab) { + return switch (tab) { + ProposalsPageTab.total => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional.empty(), + author: const Optional.empty(), ), - ); - - final signal = PageReadyProposalsSignal(page: mappedPage); - - emitSignal(signal); + ProposalsPageTab.drafts => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.draft), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + ), + ProposalsPageTab.finals => _cache.filters.copyWith( + status: const Optional(ProposalStatusFilter.aFinal), + isFavorite: const Optional.empty(), + author: const Optional.empty(), + ), + ProposalsPageTab.favorites => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional(true), + author: const Optional.empty(), + ), + ProposalsPageTab.my => _cache.filters.copyWith( + status: const Optional.empty(), + isFavorite: const Optional.empty(), + author: Optional(_cache.activeAccountId), + ), + }; } void _handleActiveAccountIdChange(CatalystId? id) { - changeFilters(author: Optional(id), resetProposals: true); + _cache = _cache.copyWith(activeAccountId: Optional(id)); + final isMyTab = _cache.tab == ProposalsPageTab.my; + + changeFilters(resetProposals: isMyTab); } - void _handleFavoriteProposalsIds(List ids) { - emit(state.copyWith(favoritesIds: ids)); - _emitCachedProposalsPage(); + void _handleProposalsChange(Page page) { + _logger.finest( + 'Got page[${page.page}] with proposals[${page.items.length}]. ' + 'Total[${page.total}]', + ); + + final requestCompleter = _proposalsRequestCompleter; + if (requestCompleter != null && !requestCompleter.isCompleted) { + requestCompleter.complete(); + } + + _cache = _cache.copyWith(page: Optional(page)); + + emitSignal(PageReadyProposalsSignal(page: page)); } - void _handleProposalsCount(ProposalsCount count) { - _cache = _cache.copyWith(count: count); + void _handleProposalsCountChange(Map data) { + _logger.finest('Proposals count changed: $data'); - emit(state.copyWith(count: count)); + emit(state.copyWith(count: Map.unmodifiable(data))); } Future _loadCampaignCategories() async { - final campaign = await _campaignService.getActiveCampaign(); + final campaign = await _campaign; _cache = _cache.copyWith(categories: Optional(campaign?.categories)); @@ -257,14 +289,14 @@ final class ProposalsCubit extends Cubit } void _rebuildCategories() { - final selectedCategory = _cache.filters.category; + final selectedCategory = _cache.filters.categoryId; final categories = _cache.categories ?? const []; final items = categories.map((e) { return ProposalsCategorySelectorItem( ref: e.selfRef, name: e.formattedCategoryName, - isSelected: e.selfRef.id == selectedCategory?.id, + isSelected: e.selfRef.id == selectedCategory, ); }).toList(); @@ -274,10 +306,10 @@ final class ProposalsCubit extends Cubit } void _rebuildOrder() { - final filterType = _cache.filters.type; + final isNoStatusFilter = _cache.filters.status == null; final selectedOrder = _resolveEffectiveOrder(); - final options = filterType == ProposalsFilterType.total + final options = isNoStatusFilter ? const [ Alphabetical(), Budget(isAscending: false), @@ -296,48 +328,73 @@ final class ProposalsCubit extends Cubit emit(state.copyWith(order: order)); } + void _rebuildProposalsCountSubs() { + final streams = ProposalsPageTab.values.map((tab) { + final filters = _buildProposalsCountFilters(tab); + return _proposalService + .watchProposalsCountV2(filters: filters) + .distinct() + .map((count) => MapEntry(tab, count)); + }); + + unawaited(_proposalsCountSub?.cancel()); + _proposalsCountSub = Rx.combineLatest( + streams, + Map.fromEntries, + ).startWith({}).listen(_handleProposalsCountChange); + } + void _resetCache() { - final activeAccount = _userService.user.activeAccount; - final filters = ProposalsFilters.forActiveCampaign(author: activeAccount?.catalystId); - _cache = ProposalsCubitCache(filters: filters); + final activeAccountId = _userService.user.activeAccount?.catalystId; + final filters = ProposalsFiltersV2(campaign: ProposalsCampaignFilters.active()); + + _cache = ProposalsCubitCache( + filters: filters, + activeAccountId: activeAccountId, + ); } ProposalsOrder _resolveEffectiveOrder() { - final filterType = _cache.filters.type; - final selectedOrder = _cache.selectedOrder; + final isTotalTab = _cache.tab == ProposalsPageTab.total; + final selectedOrder = _cache.order; // skip order for non total - if (filterType != ProposalsFilterType.total) { + if (!isTotalTab) { return const UpdateDate(isAscending: false); } return selectedOrder ?? const Alphabetical(); } - Future _updateFavoriteProposal( - DocumentRef ref, { - required bool isFavorite, - }) async { - try { - if (isFavorite) { - await _proposalService.addFavoriteProposal(ref: ref); + void _updateFavoriteProposalLocally(DocumentRef ref, bool isFavorite) { + final count = Map.of(state.count) + ..update( + ProposalsPageTab.favorites, + (value) => value + (isFavorite ? 1 : -1), + ifAbsent: () => (isFavorite ? 1 : 0), + ); + + emit(state.copyWith(count: Map.unmodifiable(count))); + + final page = _cache.page; + if (page != null) { + var items = List.of(page.items); + if (_cache.tab != ProposalsPageTab.favorites || isFavorite) { + items = items + .map((e) => e.selfRef == ref ? e.copyWith(isFavorite: isFavorite) : e) + .toList(); } else { - await _proposalService.removeFavoriteProposal(ref: ref); + items = items.where((element) => element.selfRef != ref).toList(); } - } catch (error, stack) { - _logger.severe('Updating proposal[$ref] favorite failed', error, stack); - emitError(LocalizedException.create(error)); - } - } + final diff = page.items.length - items.length; - void _watchProposalsCount({ - required ProposalsCountFilters filters, - }) { - unawaited(_proposalsCountSub?.cancel()); - _proposalsCountSub = _proposalService - .watchProposalsCount(filters: filters) - .distinct() - .listen(_handleProposalsCount); + final updatedPage = page.copyWith( + items: items, + total: page.total - diff, + ); + + _handleProposalsChange(updatedPage); + } } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart index b6a8ac3641d8..09c8085dc59b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_cubit_cache.dart @@ -1,48 +1,55 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:equatable/equatable.dart'; final class ProposalsCubitCache extends Equatable { final Campaign? campaign; - final Page? page; - final ProposalsFilters filters; - final ProposalsOrder? selectedOrder; + final CatalystId? activeAccountId; + final ProposalsFiltersV2 filters; + final ProposalsPageTab? tab; + final ProposalsOrder? order; final List? categories; - final ProposalsCount count; + final Page? page; const ProposalsCubitCache({ this.campaign, - this.page, - this.filters = const ProposalsFilters(), - this.selectedOrder, + this.activeAccountId, + this.tab, + this.filters = const ProposalsFiltersV2(), + this.order, this.categories, - this.count = const ProposalsCount(), + this.page, }); @override List get props => [ campaign, - page, + activeAccountId, + tab, filters, - selectedOrder, + order, categories, - count, + page, ]; ProposalsCubitCache copyWith({ Optional? campaign, - Optional>? page, - ProposalsFilters? filters, - Optional? selectedOrder, + Optional? activeAccountId, + Optional? tab, + ProposalsFiltersV2? filters, + Optional? order, Optional>? categories, - ProposalsCount? count, + Map? proposalsCountFilters, + Optional>? page, }) { return ProposalsCubitCache( campaign: campaign.dataOr(this.campaign), - page: page.dataOr(this.page), + activeAccountId: activeAccountId.dataOr(this.activeAccountId), + tab: tab.dataOr(this.tab), filters: filters ?? this.filters, - selectedOrder: selectedOrder.dataOr(this.selectedOrder), + order: order.dataOr(this.order), categories: categories.dataOr(this.categories), - count: count ?? this.count, + page: page.dataOr(this.page), ); } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart index 489037855810..9a4ecd9ff5c1 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposals/proposals_state.dart @@ -28,8 +28,7 @@ final class ProposalsOrderState extends Equatable { /// The state of available proposals. class ProposalsState extends Equatable { final bool hasSearchQuery; - final List favoritesIds; - final ProposalsCount count; + final Map count; final ProposalsCategoryState category; final Duration recentProposalsMaxAge; final bool isRecentProposalsEnabled; @@ -38,8 +37,7 @@ class ProposalsState extends Equatable { const ProposalsState({ this.hasSearchQuery = false, - this.favoritesIds = const [], - this.count = const ProposalsCount(), + this.count = const {}, this.category = const ProposalsCategoryState(), required this.recentProposalsMaxAge, this.isRecentProposalsEnabled = false, @@ -61,7 +59,6 @@ class ProposalsState extends Equatable { @override List get props => [ hasSearchQuery, - favoritesIds, count, category, recentProposalsMaxAge, @@ -72,8 +69,7 @@ class ProposalsState extends Equatable { ProposalsState copyWith({ bool? hasSearchQuery, - List? favoritesIds, - ProposalsCount? count, + Map? count, ProposalsCategoryState? category, Duration? recentProposalsMaxAge, bool? isRecentProposalsEnabled, @@ -82,7 +78,6 @@ class ProposalsState extends Equatable { }) { return ProposalsState( hasSearchQuery: hasSearchQuery ?? this.hasSearchQuery, - favoritesIds: favoritesIds ?? this.favoritesIds, count: count ?? this.count, category: category ?? this.category, recentProposalsMaxAge: recentProposalsMaxAge ?? this.recentProposalsMaxAge, @@ -92,8 +87,6 @@ class ProposalsState extends Equatable { ); } - bool isFavorite(String proposalId) => favoritesIds.contains(proposalId); - List tabs({required bool isProposerUnlock}) { return ProposalsPageTab.values .where((tab) => tab != ProposalsPageTab.my || isProposerUnlock) diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart index 2f6e5d538056..3b0acd75d329 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/catalyst_voices_models.dart @@ -94,6 +94,7 @@ export 'proposal/proposal_with_context.dart'; export 'proposals/proposals_count.dart'; export 'proposals/proposals_count_filters.dart'; export 'proposals/proposals_filters.dart'; +export 'proposals/proposals_filters_v2.dart'; export 'proposals/proposals_order.dart'; export 'registration/account_submit_data.dart'; export 'registration/registration.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart index 05066f1c8bcf..b8d4c07b03b9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/pagination/page.dart @@ -42,6 +42,18 @@ base class Page extends Equatable { ); } + Page copyWith({ + int? total, + List? items, + }) { + return Page( + page: page, + maxPerPage: maxPerPage, + total: total ?? this.total, + items: items ?? this.items, + ); + } + Page copyWithItems(List items) { return Page( page: page, diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart new file mode 100644 index 000000000000..1e444cd28247 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_filters_v2.dart @@ -0,0 +1,142 @@ +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:equatable/equatable.dart'; + +/// A set of filters to be applied when querying for campaign proposals. +final class ProposalsCampaignFilters extends Equatable { + /// Filters proposals by their category IDs. + final Set categoriesIds; + + /// Creates a set of filters for querying campaign proposals. + const ProposalsCampaignFilters({ + required this.categoriesIds, + }); + + /// Currently hardcoded active campaign helper constructor. + factory ProposalsCampaignFilters.active() { + final categoriesIds = activeConstantDocumentRefs.map((e) => e.category.id).toSet(); + return ProposalsCampaignFilters(categoriesIds: categoriesIds); + } + + @override + List get props => [categoriesIds]; + + @override + String toString() => 'categoriesIds: $categoriesIds'; +} + +/// A set of filters to be applied when querying for proposals. +final class ProposalsFiltersV2 extends Equatable { + /// Filters proposals by their effective status. If null, this filter is not applied. + final ProposalStatusFilter? status; + + /// Filters proposals based on whether they are marked as a favorite. + /// If null, this filter is not applied. + final bool? isFavorite; + + /// Filters proposals to only include those signed by this [author]. + /// If null, this filter is not applied. + final CatalystId? author; + + /// Filters proposals by their category ID. + /// If null, this filter is not applied. + final String? categoryId; + + /// A search term to match against proposal titles or author names. + /// If null, no text search is performed. + final String? searchQuery; + + /// Filters proposals to only include those created within the [latestUpdate] duration + /// from the current time. + /// If null, this filter is not applied. + final Duration? latestUpdate; + + /// Filters proposals based on their campaign categories. + /// If [campaign] is not null and [categoryId] is not included, empty list will be returned. + /// If null, this filter is not applied. + final ProposalsCampaignFilters? campaign; + + /// Creates a set of filters for querying proposals. + const ProposalsFiltersV2({ + this.status, + this.isFavorite, + this.author, + this.categoryId, + this.searchQuery, + this.latestUpdate, + this.campaign, + }); + + @override + List get props => [ + status, + isFavorite, + author, + categoryId, + searchQuery, + latestUpdate, + campaign, + ]; + + ProposalsFiltersV2 copyWith({ + Optional? status, + Optional? isFavorite, + Optional? author, + Optional? categoryId, + Optional? searchQuery, + Optional? latestUpdate, + Optional? campaign, + }) { + return ProposalsFiltersV2( + status: status.dataOr(this.status), + isFavorite: isFavorite.dataOr(this.isFavorite), + author: author.dataOr(this.author), + categoryId: categoryId.dataOr(this.categoryId), + searchQuery: searchQuery.dataOr(this.searchQuery), + latestUpdate: latestUpdate.dataOr(this.latestUpdate), + campaign: campaign.dataOr(this.campaign), + ); + } + + @override + String toString() { + final buffer = StringBuffer('ProposalsFiltersV2('); + final parts = []; + + if (status != null) { + parts.add('status: $status'); + } + if (isFavorite != null) { + parts.add('isFavorite: $isFavorite'); + } + if (author != null) { + parts.add('author: $author'); + } + if (categoryId != null) { + parts.add('categoryId: $categoryId'); + } + if (searchQuery != null) { + parts.add('searchQuery: "$searchQuery"'); + } + if (latestUpdate != null) { + parts.add('latestUpdate: $latestUpdate'); + } + if (campaign != null) { + parts.add('campaign: $campaign'); + } + + buffer + ..write(parts.isNotEmpty ? parts.join(', ') : 'no filters') + ..write(')'); + + return buffer.toString(); + } +} + +/// An enum representing the status of a proposal for filtering purposes. +enum ProposalStatusFilter { + /// Represents a final, submitted proposal. + aFinal, + + /// Represents a proposal that is still in draft state. + draft, +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart index 399bec665141..9231027095e2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_models/lib/src/proposals/proposals_order.dart @@ -1,7 +1,7 @@ import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:equatable/equatable.dart'; -/// Orders base on [Proposal.title]. +/// Orders proposals based on their [Proposal.title] in alphabetical order. final class Alphabetical extends ProposalsOrder { const Alphabetical(); @@ -9,12 +9,14 @@ final class Alphabetical extends ProposalsOrder { String toString() => 'Alphabetical'; } -/// Order base on [Proposal.fundsRequested]. +/// Orders proposals based on their [Proposal.fundsRequested]. /// -/// The [isAscending] parameter determines the direction of the sort: -/// - true: Lowest budget to highest budget. -/// - false: Highest budget to lowest budget. +/// The sorting direction can be specified as ascending or descending. final class Budget extends ProposalsOrder { + /// Determines the sorting direction. + /// + /// If `true`, proposals are sorted from the lowest budget to the highest. + /// If `false`, they are sorted from the highest budget to the lowest. final bool isAscending; const Budget({ @@ -28,10 +30,11 @@ final class Budget extends ProposalsOrder { String toString() => 'Budget(${isAscending ? 'asc' : 'desc'})'; } -/// A base sealed class representing different ways to order [Proposal]. +/// A base sealed class that defines different strategies for ordering a list of [Proposal] objects. /// -/// This allows us to define a fixed set of ordering types, -/// where each type can potentially hold its own specific data or logic. +/// Subclasses of [ProposalsOrder] represent specific ordering methods, +/// such as by title, budget, or update date. This sealed class ensures that only a +/// predefined set of ordering types can be created, providing type safety. sealed class ProposalsOrder extends Equatable { const ProposalsOrder(); @@ -39,18 +42,30 @@ sealed class ProposalsOrder extends Equatable { List get props => []; } -/// Orders base on [Proposal] version. +/// Orders proposals based on their last update date, which corresponds to the [Proposal] version. /// -/// The [isAscending] parameter determines the direction of the sort: -/// - true: Oldest to newest. -/// - false: Newest to oldest. +/// The sorting direction can be specified as ascending (oldest to newest) +/// or descending (newest to oldest). final class UpdateDate extends ProposalsOrder { + /// Determines the sorting direction. + /// + /// If `true`, proposals are sorted from oldest to newest. + /// If `false`, they are sorted from newest to oldest. final bool isAscending; + /// Creates an instance of [UpdateDate] order. + /// + /// The [isAscending] parameter is required to specify the sorting direction. const UpdateDate({ required this.isAscending, }); + /// Creates an instance that sorts proposals in ascending order (oldest to newest). + const UpdateDate.asc() : this(isAscending: true); + + /// Creates an instance that sorts proposals in descending order (newest to oldest). + const UpdateDate.desc() : this(isAscending: false); + @override List get props => [isAscending]; diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart index 7c687810da8c..9e60560a2b43 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/api/local/local_cat_gateway.dart @@ -26,7 +26,7 @@ String _testAccountAuthorGetter(DocumentRef ref) { } String _v7() { - final config = u.V7Options(_time--, null); + final config = u.V7Options(_time -= 2000, null); return const u.Uuid().v7(config: config); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart index 5903144aa756..912d63efdeb9 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/database/dao/proposals_v2_dao.dart @@ -15,7 +15,22 @@ import 'package:rxdart/rxdart.dart'; /// Data Access Object for Proposal-specific queries. /// -/// This DAO handles complex queries for retrieving proposals with proper status handling. +/// Handles complex queries for retrieving proposals with proper status handling +/// based on proposal actions (draft/final/hide). +/// +/// **Status Resolution Logic:** +/// - Draft (default): No action exists, or latest action is 'draft' +/// - Final: Latest action is 'final' with optional ref_ver pointing to specific version +/// - Hide: Latest action is 'hide' - excludes all versions of the proposal +/// +/// **Version Selection:** +/// - For draft: Returns latest version by createdAt +/// - For final: Returns version specified in action's ref_ver, or latest if ref_ver is null/empty +/// - For hide: Returns nothing (filtered out) +/// +/// **Performance Characteristics:** +/// - Uses composite indices for efficient GROUP BY and JOIN operations +/// - Single-query CTE approach (no N+1 queries) @DriftAccessor( tables: [ DocumentsV2, @@ -44,38 +59,55 @@ class DriftProposalsV2Dao extends DatabaseAccessor return query.getSingleOrNull(); } - /// Retrieves a paginated list of proposal briefs. - /// - /// Query Logic: - /// 1. Finds latest version of each proposal - /// 2. Finds latest action (draft/final/hide) for each proposal - /// 3. Determines effective version: - /// - If hide action: exclude all versions - /// - If final action with ref_ver: use that specific version - /// - Otherwise: use latest version - /// 4. Returns paginated results ordered by version (descending) - /// - /// Indices Used: - /// - idx_documents_v2_type_id: for latest_proposals GROUP BY - /// - idx_documents_v2_type_ref_id: for latest_actions GROUP BY - /// - idx_documents_v2_type_ref_id_ver: for action_status JOIN - /// - idx_documents_v2_type_id_ver: for final document retrieval - /// - /// Performance: - /// - ~20-50ms for typical page query (10k documents) - /// - Uses covering indices to avoid table lookups + /// Retrieves a paginated list of proposal briefs with filtering, ordering, and status handling. + /// + /// **Query Logic:** + /// 1. Finds latest version of each proposal using MAX(ver) GROUP BY id + /// 2. Finds latest action for each proposal using MAX(ver) GROUP BY ref_id + /// 3. Determines effective version based on action type: + /// - Hide action: Excludes all versions of that proposal + /// - Final action with ref_ver: Uses specific version pointed to by action + /// - Final action without ref_ver OR Draft action OR no action: Uses latest version + /// 4. Joins with templates, comments count, and favorite status + /// 5. Applies all filters and ordering + /// 6. Returns paginated results + /// + /// **Indices Used:** + /// - idx_documents_v2_type_id: For latest_proposals CTE (GROUP BY optimization) + /// - idx_documents_v2_type_ref_id: For latest_actions CTE (GROUP BY optimization) + /// - idx_documents_v2_type_ref_id_ver: For action_status JOIN + /// - idx_documents_v2_type_id_ver: For final document retrieval + /// + /// **Performance:** /// - Single query with CTEs (no N+1 queries) + /// + /// **Parameters:** + /// - [request]: Pagination parameters (page number and size) + /// - [order]: Sort order for results (default: UpdateDate.desc()) + /// - [filters]: Optional filters. + /// + /// **Returns:** Page object containing items, total count, and pagination metadata @override - Future> getProposalsBriefPage(PageRequest request) async { + Future> getProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) async { final effectivePage = math.max(request.page, 0); final effectiveSize = request.size.clamp(0, 999); - if (effectiveSize == 0) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters, size: effectiveSize); + if (shouldReturn) { return Page.empty(page: effectivePage, maxPerPage: effectiveSize); } - final items = await _queryVisibleProposalsPage(effectivePage, effectiveSize).get(); - final total = await _countVisibleProposals().getSingle(); + final items = await _queryVisibleProposalsPage( + effectivePage, + effectiveSize, + order: order, + filters: filters, + ).get(); + final total = await _countVisibleProposals(filters: filters).getSingle(); return Page( items: items, @@ -85,6 +117,18 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); } + @override + Future getVisibleProposalsCount({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters); + if (shouldReturn) { + return Future.value(0); + } + + return _countVisibleProposals(filters: filters).getSingle(); + } + @override Future updateProposalFavorite({ required String id, @@ -105,16 +149,26 @@ class DriftProposalsV2Dao extends DatabaseAccessor } @override - Stream> watchProposalsBriefPage(PageRequest request) { + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { final effectivePage = math.max(request.page, 0); final effectiveSize = request.size.clamp(0, 999); - if (effectiveSize == 0) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters, size: effectiveSize); + if (shouldReturn) { return Stream.value(Page.empty(page: effectivePage, maxPerPage: effectiveSize)); } - final itemsStream = _queryVisibleProposalsPage(effectivePage, effectiveSize).watch(); - final totalStream = _countVisibleProposals().watchSingle(); + final itemsStream = _queryVisibleProposalsPage( + effectivePage, + effectiveSize, + order: order, + filters: filters, + ).watch(); + final totalStream = _countVisibleProposals(filters: filters).watchSingle(); return Rx.combineLatest2, int, Page>( itemsStream, @@ -128,27 +182,147 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); } + @override + Stream watchVisibleProposalsCount({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + final shouldReturn = _shouldReturnEarlyFor(filters: filters); + if (shouldReturn) { + return Stream.value(0); + } + + return _countVisibleProposals(filters: filters).watchSingle(); + } + + /// Builds SQL WHERE clauses from the provided filters. + /// + /// Translates high-level filter objects into SQL conditions that can be + /// injected into the main query. + /// + /// **Security:** + /// - Uses _escapeForSqlLike for LIKE patterns + /// - Uses _escapeSqlString for direct string comparisons + /// - Protects against SQL injection through proper escaping + /// + /// **Returns:** List of SQL WHERE clause strings (without leading WHERE/AND) + List _buildFilterClauses(ProposalsFiltersV2 filters) { + final clauses = []; + + if (filters.status != null) { + if (filters.status == ProposalStatusFilter.draft) { + // NULL = no action = draft (default), or explicit 'draft' action + clauses.add("(ep.action_type IS NULL OR ep.action_type = 'draft')"); + } else { + // Final requires explicit 'final' action + clauses.add("ep.action_type = 'final'"); + } + } + + if (filters.isFavorite != null) { + clauses.add( + filters.isFavorite! + ? 'dlm.is_favorite = 1' + : '(dlm.is_favorite IS NULL OR dlm.is_favorite = 0)', + ); + } + + if (filters.author != null) { + final authorUri = filters.author.toString(); + final escapedAuthor = _escapeForSqlLike(authorUri); + clauses.add("p.authors LIKE '%$escapedAuthor%' ESCAPE '\\'"); + } + + if (filters.categoryId != null) { + final escapedCategory = _escapeSqlString(filters.categoryId!); + clauses.add("p.category_id = '$escapedCategory'"); + } else if (filters.campaign != null) { + final escapedIds = filters.campaign!.categoriesIds + .map((id) => "'${_escapeSqlString(id)}'") + .join(', '); + clauses.add('p.category_id IN ($escapedIds)'); + } + + if (filters.searchQuery != null && filters.searchQuery!.isNotEmpty) { + final escapedQuery = _escapeForSqlLike(filters.searchQuery!); + clauses.add( + ''' + ( + p.authors LIKE '%$escapedQuery%' ESCAPE '\\' OR + json_extract(p.content, '\$.setup.proposer.applicant') LIKE '%$escapedQuery%' ESCAPE '\\' OR + json_extract(p.content, '\$.setup.title.title') LIKE '%$escapedQuery%' ESCAPE '\\' + )''', + ); + } + + if (filters.latestUpdate != null) { + final cutoffTime = DateTime.now().subtract(filters.latestUpdate!); + final escapedTimestamp = _escapeSqlString(cutoffTime.toIso8601String()); + clauses.add("p.created_at >= '$escapedTimestamp'"); + } + + return clauses; + } + + /// Builds the ORDER BY clause based on the provided ordering. + /// + /// Supports multiple ordering strategies: + /// - UpdateDate: Sort by createdAt (newest first or oldest first) + /// - Funds: Sort by requested funds amount extracted from JSON content + /// + /// **Returns:** SQL ORDER BY clause string (without leading "ORDER BY") + String _buildOrderByClause(ProposalsOrder order) { + return switch (order) { + Alphabetical() => + "LOWER(NULLIF(json_extract(p.content, '\$.${ProposalDocument.titleNodeId.value}'), '')) ASC NULLS LAST", + Budget(:final isAscending) => + isAscending + ? "CAST(json_extract(p.content, '\$.${ProposalDocument.requestedFundsNodeId.value}') AS INTEGER) ASC NULLS LAST" + : "CAST(json_extract(p.content, '\$.${ProposalDocument.requestedFundsNodeId.value}') AS INTEGER) DESC NULLS LAST", + UpdateDate(:final isAscending) => isAscending ? 'p.ver ASC' : 'p.ver DESC', + }; + } + + /// Generates a SQL string of aliased column names for a given table. + /// + /// This is used in complex `JOIN` queries where two tables of the same type + /// (e.g., `documents_v2` for proposals and `documents_v2` for templates) are + /// joined. To avoid column name collisions in the result set, this function + /// prefixes each column with a unique identifier. + /// + /// Example: `_buildPrefixedColumns('p', 'p')` might produce: + /// `"p.id as p_id, p.ver as p_ver, ..."` + /// + /// - [tableAlias]: The alias used for the table in the SQL query (e.g., 'p'). + /// - [prefix]: The prefix to add to each column name in the `AS` clause (e.g., 'p'). + /// + /// Returns: A comma-separated string of aliased column names. String _buildPrefixedColumns(String tableAlias, String prefix) { return documentsV2.$columns .map((col) => '$tableAlias.${col.$name} as ${prefix}_${col.$name}') - .join(', \n'); + .join(', \n '); } - /// Counts total number of effective (non-hidden) proposals. + /// Counts total number of visible (non-hidden) proposals matching the filters. /// - /// This query mirrors the pagination query but only counts results. - /// It uses the same CTE logic to identify hidden proposals and exclude them. + /// Uses the same CTE logic as the main pagination query but stops after + /// determining the effective proposals set. This ensures the count matches + /// exactly what would be returned across all pages. /// - /// Optimization: - /// - Stops after CTE 5 (doesn't need full document retrieval) - /// - Uses COUNT(DISTINCT lp.id) to count unique proposal ids + /// **Query Strategy:** + /// - Reuses CTE structure from main query up to effective_proposals + /// - Applies same filter logic to ensure consistency + /// - Counts DISTINCT proposal ids (not versions) /// - Faster than pagination query since no document joining needed /// - /// Must match pagination query's filtering logic exactly eg.[_queryVisibleProposalsPage] - /// - /// Returns: Total count of visible proposals (not including hidden) - Selectable _countVisibleProposals() { - const cteQuery = r''' + /// **Returns:** Selectable that can be used with getSingle() or watchSingle() + Selectable _countVisibleProposals({ + required ProposalsFiltersV2 filters, + }) { + final filterClauses = _buildFilterClauses(filters); + final whereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; + + final cteQuery = + ''' WITH latest_proposals AS ( SELECT id, MAX(ver) as max_ver FROM documents_v2 @@ -164,19 +338,32 @@ class DriftProposalsV2Dao extends DatabaseAccessor action_status AS ( SELECT a.ref_id, - json_extract(a.content, '$.action') as action_type + a.ref_ver, + COALESCE(json_extract(a.content, '\$.action'), 'draft') as action_type FROM documents_v2 a INNER JOIN latest_actions la ON a.ref_id = la.ref_id AND a.ver = la.max_action_ver WHERE a.type = ? ), - hidden_proposals AS ( - SELECT ref_id - FROM action_status - WHERE action_type = 'hide' + effective_proposals AS ( + SELECT + lp.id, + CASE + WHEN ast.action_type = 'final' AND ast.ref_ver IS NOT NULL AND ast.ref_ver != '' THEN ast.ref_ver + ELSE lp.max_ver + END as ver, + ast.action_type + FROM latest_proposals lp + LEFT JOIN action_status ast ON lp.id = ast.ref_id + WHERE NOT EXISTS ( + SELECT 1 FROM action_status hidden + WHERE hidden.ref_id = lp.id AND hidden.action_type = 'hide' + ) ) - SELECT COUNT(DISTINCT lp.id) as total - FROM latest_proposals lp - WHERE lp.id NOT IN (SELECT ref_id FROM hidden_proposals) + SELECT COUNT(DISTINCT ep.id) as total + FROM effective_proposals ep + INNER JOIN documents_v2 p ON ep.id = p.id AND ep.ver = p.ver + LEFT JOIN documents_local_metadata dlm ON p.id = dlm.id + WHERE p.type = ? $whereClause '''; return customSelect( @@ -185,18 +372,117 @@ class DriftProposalsV2Dao extends DatabaseAccessor Variable.withString(DocumentType.proposalDocument.uuid), Variable.withString(DocumentType.proposalActionDocument.uuid), Variable.withString(DocumentType.proposalActionDocument.uuid), + Variable.withString(DocumentType.proposalDocument.uuid), ], - readsFrom: {documentsV2}, + readsFrom: { + documentsV2, + if (filters.isFavorite != null) documentsLocalMetadata, + }, ).map((row) => row.readNullable('total') ?? 0); } - /// Fetches paginated proposal pages using complex CTE logic. + /// Escapes a string for use in a SQL `LIKE` clause with a custom escape character. + /// + /// This method prepares an input string to be safely used as a pattern in a + /// 'LIKE' query. It escapes the standard SQL `LIKE` wildcards (`%` and `_`) + /// and the chosen escape character (`\`) itself, preventing them from being + /// interpreted as wildcards. It also escapes single quotes to prevent SQL + /// injection. + /// + /// The `ESCAPE '\'` clause must be used in the SQL query where the output of + /// this function is used. + /// + /// Escapes: + /// - `\` is replaced with `\\` + /// - `%` is replaced with `\%` + /// - `_` is replaced with `\_` + /// - `'` is replaced with `''` + /// + /// - [input]: The string to escape. + /// + /// Returns: The escaped string, safe for use in a `LIKE` clause. + String _escapeForSqlLike(String input) { + return input + .replaceAll(r'\', r'\\') + .replaceAll('%', r'\%') + .replaceAll('_', r'\_') + .replaceAll("'", "''"); + } + + /// Escapes single quotes in a string for safe use in an SQL query. + /// + /// Replaces each single quote (`'`) with two single quotes (`''`). This is the + /// standard way to escape single quotes in SQL strings, preventing unterminated + /// string literals and SQL injection. + /// + /// - [input]: The string to escape. + /// + /// Returns: The escaped string. + String _escapeSqlString(String input) { + return input.replaceAll("'", "''"); + } + + /// Fetches a page of visible proposals using multi-stage CTE logic. + /// + /// **CTE Pipeline:** + /// + /// 1. **latest_proposals** + /// - Groups all proposals by id and finds MAX(ver) + /// - Identifies the newest version of each proposal + /// - Uses: idx_documents_v2_type_id + /// + /// 2. **version_lists** + /// - Collects all version ids for each proposal into comma-separated string + /// - Ordered by ver ASC for consistent version history + /// - Used to show version dropdown in UI + /// + /// 3. **latest_actions** + /// - Groups all proposal actions by ref_id and finds MAX(ver) + /// - Ensures we only check the most recent action per proposal + /// - Uses: idx_documents_v2_type_ref_id + /// + /// 4. **action_status** + /// - Joins actual action documents with latest_actions + /// - Extracts action type ('draft'/'final'/'hide') from JSON content + /// - Extracts ref_ver which may point to specific proposal version + /// - COALESCE defaults to 'draft' when action field is missing + /// - Uses: idx_documents_v2_type_ref_id_ver + /// + /// 5. **effective_proposals** + /// - Applies version resolution logic: + /// * Hide action: Filtered out by WHERE NOT EXISTS + /// * Final action with ref_ver: Uses ref_ver (specific pinned version) + /// * Final action without ref_ver OR draft OR no action: Uses max_ver (latest) + /// - LEFT JOIN ensures proposals without actions are included (default to draft) /// - /// Returns: Selectable of [JoinedProposalBriefEntity] mapped from raw rows of customSelect. - /// This may be used as single get of watch. - Selectable _queryVisibleProposalsPage(int page, int size) { + /// 6. **comments_count** + /// - Counts comments per proposal version + /// - Joins on both ref_id and ref_ver for version-specific counts + /// + /// **Final Query:** + /// - Joins documents_v2 with effective_proposals to get full document data + /// - LEFT JOINs with comments, favorites, and template for enrichment + /// - Applies all user-specified filters + /// - Orders and paginates results + /// + /// **Index Usage:** + /// - idx_documents_v2_type_id: For GROUP BY in latest_proposals + /// - idx_documents_v2_type_ref_id: For GROUP BY in latest_actions + /// - idx_documents_v2_type_ref_id_ver: For action_status JOIN + /// - idx_documents_v2_type_id_ver: For final document retrieval + /// + /// **Returns:** Selectable that can be used with .get() or .watch() + Selectable _queryVisibleProposalsPage( + int page, + int size, { + required ProposalsOrder order, + required ProposalsFiltersV2 filters, + }) { final proposalColumns = _buildPrefixedColumns('p', 'p'); final templateColumns = _buildPrefixedColumns('t', 't'); + final orderByClause = _buildOrderByClause(order); + final filterClauses = _buildFilterClauses(filters); + final whereClause = filterClauses.isEmpty ? '' : 'AND ${filterClauses.join(' AND ')}'; final cteQuery = ''' @@ -271,8 +557,8 @@ class DriftProposalsV2Dao extends DatabaseAccessor LEFT JOIN comments_count cc ON p.id = cc.ref_id AND p.ver = cc.ref_ver LEFT JOIN documents_local_metadata dlm ON p.id = dlm.id LEFT JOIN documents_v2 t ON p.template_id = t.id AND p.template_ver = t.ver AND t.type = ? - WHERE p.type = ? - ORDER BY p.ver DESC + WHERE p.type = ? $whereClause + ORDER BY $orderByClause LIMIT ? OFFSET ? '''; @@ -323,69 +609,151 @@ class DriftProposalsV2Dao extends DatabaseAccessor ); }); } + + bool _shouldReturnEarlyFor({ + required ProposalsFiltersV2 filters, + int? size, + }) { + if (size != null && size == 0) { + return true; + } + + final campaign = filters.campaign; + if (campaign != null) { + assert( + campaign.categoriesIds.length <= 100, + 'Campaign filter with more than 100 categories may impact performance. ' + 'Consider pagination or alternative filtering strategy.', + ); + + if (campaign.categoriesIds.isEmpty) { + return true; + } + + if (filters.categoryId != null && !campaign.categoriesIds.contains(filters.categoryId)) { + return true; + } + } + + return false; + } } /// Public interface for proposal queries. /// -/// This interface defines the contract for proposal data access. -/// Implementations should respect proposal status (draft/final/hide) and -/// provide efficient pagination for large datasets. +/// Defines the contract for proposal data access with proper status handling. +/// +/// **Status Semantics:** +/// - Draft: Proposal is work-in-progress, shows latest version +/// - Final: Proposal is submitted, shows specific or latest version +/// - Hide: Proposal is hidden from all views abstract interface class ProposalsV2Dao { /// Retrieves a single proposal by its reference. /// /// Filters by type == proposalDocument. /// - /// Parameters: + /// **Parameters:** /// - ref: Document reference with id (required) and version (optional) /// - /// Behavior: + /// **Behavior:** /// - If ref.isExact (has version): Returns specific version /// - If ref.isLoose (no version): Returns latest version by createdAt /// - Returns null if no matching proposal found /// - /// Returns: DocumentEntityV2 or null + /// **Note:** This method does NOT respect proposal actions (draft/final/hide). + /// It returns the raw document data. Use getProposalsBriefPage for status-aware queries. + /// + /// **Returns:** [DocumentEntityV2] or null Future getProposal(DocumentRef ref); - /// Retrieves a paginated page of proposal briefs. + /// Retrieves a paginated page of proposal briefs with filtering and ordering. /// /// Filters by type == proposalDocument. /// Returns latest effective version per id, respecting proposal actions. /// - /// Status Handling: + /// **Status Handling:** /// - Draft (default): Display latest version /// - Final: Display specific version if ref_ver set, else latest /// - Hide: Exclude all versions /// - /// Pagination: + /// **Pagination:** /// - request.page: 0-based page number /// - request.size: Items per page (clamped to 999 max) /// - /// Performance: - /// - Optimized for 10k+ documents with composite indices + /// **Performance:** /// - Single query with CTEs (no N+1 queries) /// - /// Returns: Page object with items, total count, and pagination metadata - Future> getProposalsBriefPage(PageRequest request); + /// **Returns:** Page object with items, total count, and pagination metadata + Future> getProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + + /// Counts the total number of visible proposals that match the given filters. + /// + /// This method respects the same status handling logic as [getProposalsBriefPage], + /// ensuring the count is consistent with the total items that would be paginated. + /// It is more efficient than fetching all pages to get a total count. + /// + /// **Parameters:** + /// - [filters]: Optional filters to apply before counting. + /// + /// **Returns:** The total number of visible proposals. + Future getVisibleProposalsCount({ + ProposalsFiltersV2 filters, + }); /// Updates the favorite status of a proposal. /// - /// This method updates or inserts a record in the local metadata table - /// to mark a proposal as a favorite. + /// Manages local metadata to mark proposals as favorites. + /// Operates within a transaction for atomicity. + /// + /// **Parameters:** + /// - [id]: The unique identifier of the proposal + /// - [isFavorite]: Whether to mark as favorite (true) or unfavorite (false) /// - /// - [id]: The unique identifier of the proposal. - /// - [isFavorite]: A boolean indicating whether the proposal should be marked as a favorite. + /// **Behavior:** + /// - If isFavorite is true: Inserts/updates record in documents_local_metadata + /// - If isFavorite is false: Deletes record from documents_local_metadata Future updateProposalFavorite({ required String id, required bool isFavorite, }); - /// Watches for changes and returns a paginated page of proposal briefs. + /// Watches for changes and emits paginated pages of proposal briefs. + /// + /// Provides a reactive stream that emits a new [Page] whenever the + /// underlying data changes in the database. + /// + /// **Reactivity:** + /// - Emits new page when documents_v2 changes (proposals, actions) + /// - Emits new page when documents_local_metadata changes (favorites) + /// - Combines items and count streams for consistent pagination + /// + /// **Performance:** + /// - Same query optimization as [getProposalsBriefPage] + /// + /// **Returns:** Stream of Page objects with current state + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + + /// Watches for changes and emits the total count of visible proposals. + /// + /// Provides a reactive stream that emits a new integer count whenever the + /// underlying data changes in a way that affects the total number of + /// visible proposals matching the filters. /// - /// This method provides a reactive stream that emits a new [Page] of proposal - /// briefs whenever the underlying data changes in the database. It has the - /// same filtering, status handling, and pagination logic as - /// [getProposalsBriefPage]. + /// **Parameters:** + /// - [filters]: Optional filters to apply before counting. /// - /// Returns a [Stream] that emits a [Page] of [JoinedProposalBriefEntity]. - Stream> watchProposalsBriefPage(PageRequest request); + /// **Reactivity:** + /// - Emits new count when documents_v2 changes (proposals, actions) + /// that match the filter criteria. + Stream watchVisibleProposalsCount({ + ProposalsFiltersV2 filters, + }); } diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart index 778d60cbff0b..58034a586e9c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/database_documents_data_source.dart @@ -163,9 +163,13 @@ final class DatabaseDocumentsDataSource } @override - Stream> watchProposalsBriefPage(PageRequest request) { + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { return _database.proposalsV2Dao - .watchProposalsBriefPage(request) + .watchProposalsBriefPage(request: request, order: order, filters: filters) .map((page) => page.map((data) => data.toModel())); } @@ -176,6 +180,13 @@ final class DatabaseDocumentsDataSource return _database.proposalsDao.watchCount(filters: filters); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _database.proposalsV2Dao.watchVisibleProposalsCount(filters: filters); + } + @override Stream> watchProposalsPage({ required PageRequest request, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart index c3d23d36ed04..2ad7bd35d77b 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/document/source/proposal_document_data_local_source.dart @@ -25,7 +25,15 @@ abstract interface class ProposalDocumentDataLocalSource { required bool isFavorite, }); - Stream> watchProposalsBriefPage(PageRequest request); + Stream> watchProposalsBriefPage({ + required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, + }); + + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); Stream watchProposalsCount({ required ProposalsCountFilters filters, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart index fd9ddb4b77c5..e40ac9851ce2 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/lib/src/proposal/proposal_repository.dart @@ -98,12 +98,18 @@ abstract interface class ProposalRepository { Stream> watchProposalsBriefPage({ required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, }); Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, @@ -340,9 +346,11 @@ final class ProposalRepositoryImpl implements ProposalRepository { @override Stream> watchProposalsBriefPage({ required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), }) { return _proposalsLocalSource - .watchProposalsBriefPage(request) + .watchProposalsBriefPage(request: request, order: order, filters: filters) .map((page) => page.map(_mapJoinedProposalBriefData)); } @@ -353,6 +361,13 @@ final class ProposalRepositoryImpl implements ProposalRepository { return _proposalsLocalSource.watchProposalsCount(filters: filters); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _proposalsLocalSource.watchProposalsCountV2(filters: filters); + } + @override Stream> watchProposalsPage({ required PageRequest request, diff --git a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart index 9ca87705cc98..2908dd6204ff 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_repositories/test/src/database/dao/proposals_v2_dao_test.dart @@ -1,4 +1,5 @@ // ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_dynamic_calls import 'package:catalyst_voices_dev/catalyst_voices_dev.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; @@ -30,6 +31,380 @@ void main() { }); group(ProposalsV2Dao, () { + group('getVisibleProposalsCount', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + test('returns 0 for empty database', () async { + final result = await dao.getVisibleProposalsCount(); + + expect(result, 0); + }); + + test('returns correct count for proposals without actions', () async { + final entities = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'p-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + ), + ); + await db.documentsV2Dao.saveAll(entities); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 5); + }); + + test('counts only latest version of proposals with multiple versions', () async { + final entityOldV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(earliest), + ); + final entityNewV1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + final entityOldV2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(earliest), + ); + final entityNewV2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + ); + await db.documentsV2Dao.saveAll([entityOldV1, entityNewV1, entityOldV2, entityNewV2]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 2); + }); + + test('excludes hidden proposals from count', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final proposal2Ver = _buildUuidV7At(latest); + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: proposal2Ver, + ); + + final hideAction = _createTestDocumentEntity( + id: 'action-hide', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, hideAction]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + + test('excludes all versions when latest action is hide', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final proposal2V1 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(earliest), + ); + final proposal2V2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + ); + final proposal2V3 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(latest), + ); + + final hideAction = _createTestDocumentEntity( + id: 'action-hide', + ver: _buildUuidV7At(latest.add(const Duration(hours: 1))), + type: DocumentType.proposalActionDocument, + refId: 'p2', + contentData: ProposalSubmissionActionDto.hide.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + proposal1, + proposal2V1, + proposal2V2, + proposal2V3, + hideAction, + ]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + + test('counts only proposals matching category filter', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(categoryId: 'cat-1'), + ); + + expect(result, 1); + }); + + test('respects campaign categories filter', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result, 2); + }); + + test('returns 0 for empty campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + await db.documentsV2Dao.saveAll([proposal1]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {}), + ), + ); + + expect(result, 0); + }); + + test('returns 0 when categoryId not in campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + await db.documentsV2Dao.saveAll([proposal1]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-2', 'cat-3'}), + categoryId: 'cat-1', + ), + ); + + expect(result, 0); + }); + + test('respects status filter for draft proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result, 1); + }); + + test('respects status filter for final proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + ); + + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: _buildUuidV7At(earliest), + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + final result = await dao.getVisibleProposalsCount( + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.aFinal), + ); + + expect(result, 1); + }); + + test('counts proposals with authors filter', () async { + final author1 = _createTestAuthor(name: 'author1'); + final author2 = _createTestAuthor(name: 'author2', role0KeySeed: 1); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author2.toString(), + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + authors: author1.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + final result = await dao.getVisibleProposalsCount( + filters: ProposalsFiltersV2(author: author1), + ); + + expect(result, 2); + }); + + test('ignores non-proposal documents in count', () async { + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + ); + + final comment = _createTestDocumentEntity( + id: 'c1', + ver: _buildUuidV7At(latest), + type: DocumentType.commentDocument, + ); + + final template = _createTestDocumentEntity( + id: 't1', + ver: _buildUuidV7At(latest), + type: DocumentType.proposalTemplate, + ); + + await db.documentsV2Dao.saveAll([proposal, comment, template]); + + final result = await dao.getVisibleProposalsCount(); + + expect(result, 1); + }); + }); + + group('updateProposalFavorite', () { + test('marks proposal as favorite when isFavorite is true', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNotNull); + expect(metadata!.id, 'p1'); + expect(metadata.isFavorite, true); + }); + + test('removes favorite status when isFavorite is false', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + + await dao.updateProposalFavorite(id: 'p1', isFavorite: false); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNull); + }); + + test('does nothing when removing non-existent favorite', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: false); + + final metadata = await (db.select( + db.documentsLocalMetadata, + )..where((tbl) => tbl.id.equals('p1'))).getSingleOrNull(); + + expect(metadata, isNull); + }); + + test('can mark multiple proposals as favorites', () async { + await dao.updateProposalFavorite(id: 'p1', isFavorite: true); + await dao.updateProposalFavorite(id: 'p2', isFavorite: true); + await dao.updateProposalFavorite(id: 'p3', isFavorite: true); + + final favorites = await db.select(db.documentsLocalMetadata).get(); + + expect(favorites.length, 3); + expect(favorites.map((e) => e.id).toSet(), {'p1', 'p2', 'p3'}); + }); + }); + group('getProposalsBriefPage', () { final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); @@ -40,7 +415,7 @@ void main() { const request = PageRequest(page: 0, size: 10); // When - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items, isEmpty); @@ -69,7 +444,7 @@ void main() { const request = PageRequest(page: 0, size: 2); // When - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 2); @@ -96,7 +471,7 @@ void main() { const request = PageRequest(page: 1, size: 2); // When - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Returns remaining items (1), total unchanged expect(result.items.length, 1); @@ -127,7 +502,7 @@ void main() { const request = PageRequest(page: 0, size: 10); // When - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 2); @@ -155,7 +530,7 @@ void main() { const request = PageRequest(page: 0, size: 10); // When - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 1); @@ -192,7 +567,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Only visible (p1); total=1. expect(result.items.length, 1); @@ -232,7 +607,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Only visible (p1); total=1. expect(result.items.length, 1); @@ -280,7 +655,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: total=2, both are visible expect(result.items.length, 2); @@ -335,7 +710,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: With join, latest A is hidden → exclude A, total =1 (B only), items =1 (B). expect(result.total, 1); @@ -377,7 +752,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 2); @@ -417,7 +792,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 1); @@ -456,7 +831,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 1); @@ -502,7 +877,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 1); @@ -536,7 +911,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should still return the proposal (NOT IN with NULL should not fail) expect(result.items.length, 1); @@ -574,7 +949,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then expect(result.items.length, 2); @@ -615,7 +990,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should treat as draft and return latest version expect(result.items.length, 1); @@ -652,7 +1027,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should treat as draft and return latest version expect(result.items.length, 1); @@ -688,7 +1063,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should treat as draft and return latest version expect(result.items.length, 1); @@ -724,7 +1099,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should treat as draft and return latest version expect(result.items.length, 1); @@ -760,7 +1135,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should handle gracefully and return latest version expect(result.items.length, 1); @@ -796,7 +1171,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should handle gracefully and return latest version expect(result.items.length, 1); @@ -827,7 +1202,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should be hidden based on top-level action field expect(result.items.length, 0); @@ -866,7 +1241,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should be ordered newest first by createdAt expect(result.items.length, 3); @@ -894,7 +1269,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should order by createdAt (Dec 31 first), not ver string expect(result.items.length, 2); @@ -948,7 +1323,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Count should match visible items (p2 final, p3 draft) expect(result.items.length, 2); @@ -972,9 +1347,15 @@ void main() { await db.documentsV2Dao.saveAll(proposals); // When: Query multiple pages - final page1 = await dao.getProposalsBriefPage(const PageRequest(page: 0, size: 10)); - final page2 = await dao.getProposalsBriefPage(const PageRequest(page: 1, size: 10)); - final page3 = await dao.getProposalsBriefPage(const PageRequest(page: 2, size: 10)); + final page1 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 0, size: 10), + ); + final page2 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 1, size: 10), + ); + final page3 = await dao.getProposalsBriefPage( + request: const PageRequest(page: 2, size: 10), + ); // Then: Total should be consistent across all pages expect(page1.total, 25); @@ -1022,7 +1403,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should use latest version expect(result.items.length, 1); @@ -1060,7 +1441,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should use latest version expect(result.items.length, 1); @@ -1093,7 +1474,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should NOT hide (case sensitive) expect(result.items.length, 1); @@ -1129,7 +1510,7 @@ void main() { // When const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); // Then: Should treat as draft and use latest version expect(result.items.length, 1); @@ -1151,7 +1532,7 @@ void main() { await db.documentsV2Dao.save(proposal); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1174,7 +1555,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1197,7 +1578,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1219,7 +1600,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items, isEmpty); expect(result.total, 0); @@ -1251,7 +1632,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action1, action2]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1297,7 +1678,7 @@ void main() { ]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 3); @@ -1326,7 +1707,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1349,7 +1730,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, action]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.id, 'p1'); @@ -1364,7 +1745,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.versionIds.length, 1); @@ -1385,7 +1766,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal3, proposal1, proposal2]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver3); @@ -1402,7 +1783,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.commentsCount, 0); @@ -1433,7 +1814,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, comment1, comment2]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.commentsCount, 2); @@ -1477,7 +1858,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal1, proposal2, comment1, comment2, comment3]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver2); @@ -1542,7 +1923,7 @@ void main() { ]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver2); @@ -1592,7 +1973,7 @@ void main() { ]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 2); @@ -1628,7 +2009,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal, comment, otherDoc]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.commentsCount, 1); @@ -1642,7 +2023,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.isFavorite, false); @@ -1663,7 +2044,7 @@ void main() { ); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.isFavorite, false); @@ -1684,7 +2065,7 @@ void main() { ); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.isFavorite, true); @@ -1707,7 +2088,7 @@ void main() { ); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver2); @@ -1745,7 +2126,7 @@ void main() { ); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 3); @@ -1789,7 +2170,7 @@ void main() { ); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver1); @@ -1804,7 +2185,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.template, isNull); @@ -1821,7 +2202,7 @@ void main() { await db.documentsV2Dao.saveAll([proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.template, isNull); @@ -1847,7 +2228,7 @@ void main() { await db.documentsV2Dao.saveAll([template, proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.template, isNotNull); @@ -1883,7 +2264,7 @@ void main() { await db.documentsV2Dao.saveAll([template1, template2, proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.template, isNotNull); @@ -1909,7 +2290,7 @@ void main() { await db.documentsV2Dao.saveAll([template, proposal]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.template, isNull); @@ -1963,7 +2344,7 @@ void main() { ]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 3); @@ -2034,7 +2415,7 @@ void main() { ]); const request = PageRequest(page: 0, size: 10); - final result = await dao.getProposalsBriefPage(request); + final result = await dao.getProposalsBriefPage(request: request); expect(result.items.length, 1); expect(result.items.first.proposal.ver, ver1); @@ -2043,14 +2424,1796 @@ void main() { expect(result.items.first.template!.content.data['title'], 'Template 1'); }); }); - }); - }); -} -String _buildUuidV7At(DateTime dateTime) { - final ts = dateTime.millisecondsSinceEpoch; - final rand = Uint8List.fromList([42, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - return const UuidV7().generate(options: V7Options(ts, rand)); + group('Ordering', () { + test('sorts alphabetically by title', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Middle Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Middle Project', + ); + expect( + result.items[2].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + }); + + test('sorts alphabetically case-insensitively', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'zebra project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Alpha PROJECT'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'MIDDLE project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha PROJECT', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'MIDDLE project', + ); + expect( + result.items[2].proposal.content.data['setup']['title']['title'], + 'zebra project', + ); + }); + + test('sorts alphabetically with missing titles at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts alphabetically with empty string titles at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'title': {'title': 'Zebra Project'}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': ''}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Alpha Project'}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Alpha Project', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Zebra Project', + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by budget ascending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + result.items[2].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + }); + + test('sorts by budget descending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: false), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + result.items[2].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + }); + + test('sorts by budget ascending with missing values at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by budget descending with missing values at the end', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(middle), + contentData: {}, + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(latest), + contentData: { + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: false), + ); + + expect(result.items.length, 3); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect(result.items[2].proposal.id, 'id-2'); + }); + + test('sorts by update date ascending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(latest), + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(earliest), + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(middle), + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const UpdateDate.asc(), + ); + + expect(result.items.length, 3); + expect(result.items[0].proposal.id, 'id-2'); + expect(result.items[1].proposal.id, 'id-3'); + expect(result.items[2].proposal.id, 'id-1'); + }); + + test('sorts by update date descending', () async { + final entities = [ + _createTestDocumentEntity( + id: 'id-1', + ver: _buildUuidV7At(earliest), + ), + _createTestDocumentEntity( + id: 'id-2', + ver: _buildUuidV7At(latest), + ), + _createTestDocumentEntity( + id: 'id-3', + ver: _buildUuidV7At(middle), + ), + ]; + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const UpdateDate.desc(), + ); + + expect(result.items.length, 3); + expect(result.items[0].proposal.id, 'id-2'); + expect(result.items[1].proposal.id, 'id-3'); + expect(result.items[2].proposal.id, 'id-1'); + }); + + test('respects pagination', () async { + final entities = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'id-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + contentData: { + 'setup': { + 'title': {'title': 'Project ${String.fromCharCode(65 + i)}'}, + }, + }, + ), + ); + await db.documentsV2Dao.saveAll(entities); + + const request = PageRequest(page: 1, size: 2); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(result.items.length, 2); + expect(result.total, 5); + expect(result.page, 1); + expect( + result.items[0].proposal.content.data['setup']['title']['title'], + 'Project C', + ); + expect( + result.items[1].proposal.content.data['setup']['title']['title'], + 'Project D', + ); + }); + + test('works with multiple versions of same proposal', () async { + final oldVer = _buildUuidV7At(earliest); + final oldProposal = _createTestDocumentEntity( + id: 'multi-id', + ver: oldVer, + contentData: { + 'setup': { + 'title': {'title': 'Old Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final newVer = _buildUuidV7At(latest); + final newProposal = _createTestDocumentEntity( + id: 'multi-id', + ver: newVer, + contentData: { + 'setup': { + 'title': {'title': 'New Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final otherVer = _buildUuidV7At(middle); + final otherProposal = _createTestDocumentEntity( + id: 'other-id', + ver: otherVer, + contentData: { + 'setup': { + 'title': {'title': 'Middle Title'}, + }, + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([oldProposal, newProposal, otherProposal]); + + const request = PageRequest(page: 0, size: 10); + final resultAlphabetical = await dao.getProposalsBriefPage( + request: request, + order: const Alphabetical(), + ); + + expect(resultAlphabetical.items.length, 2); + expect( + resultAlphabetical.items[0].proposal.content.data['setup']['title']['title'], + 'Middle Title', + ); + expect( + resultAlphabetical.items[1].proposal.content.data['setup']['title']['title'], + 'New Title', + ); + + final resultBudget = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(resultBudget.items.length, 2); + expect( + resultBudget.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + expect( + resultBudget.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 50000, + ); + }); + + test('works with final action pointing to specific version', () async { + final ver1 = _buildUuidV7At(earliest); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: ver1, + contentData: { + 'setup': { + 'title': {'title': 'Version 1'}, + }, + 'summary': { + 'budget': {'requestedFunds': 10000}, + }, + }, + ); + + final ver2 = _buildUuidV7At(middle); + final proposal2 = _createTestDocumentEntity( + id: 'p1', + ver: ver2, + contentData: { + 'setup': { + 'title': {'title': 'Version 2'}, + }, + 'summary': { + 'budget': {'requestedFunds': 50000}, + }, + }, + ); + + final actionVer = _buildUuidV7At(latest); + final action = _createTestDocumentEntity( + id: 'action-1', + ver: actionVer, + type: DocumentType.proposalActionDocument, + refId: 'p1', + refVer: ver1, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + final otherVer = _buildUuidV7At(middle); + final otherProposal = _createTestDocumentEntity( + id: 'other-id', + ver: otherVer, + contentData: { + 'setup': { + 'title': {'title': 'Other Proposal'}, + }, + 'summary': { + 'budget': {'requestedFunds': 30000}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, action, otherProposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + order: const Budget(isAscending: true), + ); + + expect(result.items.length, 2); + expect(result.items[0].proposal.ver, ver1); + expect( + result.items[0].proposal.content.data['summary']['budget']['requestedFunds'], + 10000, + ); + expect( + result.items[1].proposal.content.data['summary']['budget']['requestedFunds'], + 30000, + ); + }); + }); + + group('Filtering', () { + final earliest = DateTime.utc(2025, 2, 5, 5, 23, 27); + final middle = DateTime.utc(2025, 2, 5, 5, 25, 33); + final latest = DateTime.utc(2025, 8, 11, 11, 20, 18); + + group('by status', () { + test('filters draft proposals without action documents', () async { + final draftProposal1 = _createTestDocumentEntity( + id: 'draft-no-action', + ver: _buildUuidV7At(latest), + ); + + final draftProposal2 = _createTestDocumentEntity( + id: 'draft-with-action', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + ); + + final draftActionVer = _buildUuidV7At(middle); + final draftAction = _createTestDocumentEntity( + id: 'action-draft', + ver: draftActionVer, + type: DocumentType.proposalActionDocument, + refId: 'draft-with-action', + contentData: ProposalSubmissionActionDto.draft.toJson(), + ); + + final finalProposalVer = _buildUuidV7At(earliest.add(const Duration(hours: 1))); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + draftProposal1, + draftProposal2, + draftAction, + finalProposal, + finalAction, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result.items.length, 2); + expect(result.total, 2); + expect( + result.items.map((e) => e.proposal.id).toSet(), + {'draft-no-action', 'draft-with-action'}, + ); + }); + + test('filters draft proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-id', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.draft), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-id'); + }); + + test('filters final proposals', () async { + final draftProposal = _createTestDocumentEntity( + id: 'draft-id', + ver: _buildUuidV7At(latest), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-id', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-id', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(status: ProposalStatusFilter.aFinal), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'final-id'); + }); + }); + + group('by favorite', () { + test('filters favorite proposals', () async { + final favoriteProposal = _createTestDocumentEntity( + id: 'favorite-id', + ver: _buildUuidV7At(latest), + ); + + final notFavoriteProposal = _createTestDocumentEntity( + id: 'not-favorite-id', + ver: _buildUuidV7At(middle), + ); + + await db.documentsV2Dao.saveAll([favoriteProposal, notFavoriteProposal]); + + await dao.updateProposalFavorite(id: 'favorite-id', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(isFavorite: true), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'favorite-id'); + expect(result.items[0].isFavorite, true); + }); + + test('filters non-favorite proposals', () async { + final favoriteProposal = _createTestDocumentEntity( + id: 'favorite-id', + ver: _buildUuidV7At(latest), + ); + + final notFavoriteProposal = _createTestDocumentEntity( + id: 'not-favorite-id', + ver: _buildUuidV7At(middle), + ); + + await db.documentsV2Dao.saveAll([favoriteProposal, notFavoriteProposal]); + + await dao.updateProposalFavorite(id: 'favorite-id', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(isFavorite: false), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'not-favorite-id'); + expect(result.items[0].isFavorite, false); + }); + }); + + group('by author', () { + test('filters proposals by author CatalystId', () async { + final author1 = _createTestAuthor(name: 'john_doe', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'alice', role0KeySeed: 2); + final author3 = _createTestAuthor(name: 'bob', role0KeySeed: 3); + + final p1Authors = [author1, author2].map((e) => e.toUri().toString()).join(','); + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: p1Authors, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author3.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: author1), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('filters proposals by different author CatalystId', () async { + final author1 = _createTestAuthor(name: 'john_doe', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'alice', role0KeySeed: 2); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: author2.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: author2), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p2'); + }); + + test('handles author with special characters in username', () async { + final authorWithSpecialChars = _createTestAuthor( + /* cSpell:disable */ + name: "test'user_100%", + /* cSpell:enable */ + role0KeySeed: 1, + ); + final normalAuthor = _createTestAuthor(name: 'normal', role0KeySeed: 2); + + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: authorWithSpecialChars.toString(), + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: normalAuthor.toString(), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2(author: authorWithSpecialChars), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by category', () { + test('filters proposals by category id', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'category-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'category-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(categoryId: 'category-1'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by search query', () { + test('searches in authors field', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: 'john-doe,jane-smith', + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + 'proposer': {'applicant': 'Other Name'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: 'alice-wonder', + contentData: { + 'setup': { + 'title': {'title': 'Different Title'}, + 'proposer': {'applicant': 'Different Name'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'john'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches in applicant name from JSON content', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: 'other-author', + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + 'proposer': {'applicant': 'John Doe'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: 'different-author', + contentData: { + 'setup': { + 'title': {'title': 'Different Title'}, + 'proposer': {'applicant': 'Jane Smith'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'John'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches in title from JSON content', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: 'other-author', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Revolution'}, + 'proposer': {'applicant': 'Other Name'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + authors: 'different-author', + contentData: { + 'setup': { + 'title': {'title': 'Smart Contracts Study'}, + 'proposer': {'applicant': 'Different Name'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'Revolution'), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('searches case-insensitively', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Revolution'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'blockchain'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('returns multiple matches from different fields', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + authors: 'tech-author', + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Tech Innovation'}, + }, + }, + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(earliest), + contentData: { + 'setup': { + 'proposer': {'applicant': 'Tech Expert'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'tech'), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + }); + }); + + group('SQL injection protection', () { + test('escapes single quotes in search query', () async { + final proposal = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': "Project with 'quotes'"}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: "Project with 'quotes'"), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('prevents SQL injection via search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'Legitimate Title'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'Other Title'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + searchQuery: "' OR '1'='1", + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('escapes LIKE wildcards in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': '100% Complete'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': '100X Complete'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: '100%'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes underscores in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': 'test_case'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + /* cSpell:disable */ + 'title': {'title': 'testXcase'}, + /* cSpell:enable */ + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: 'test_case'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes backslashes in search query', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + contentData: { + 'setup': { + 'title': {'title': r'path\to\file'}, + }, + }, + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + contentData: { + 'setup': { + 'title': {'title': 'path/to/file'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(searchQuery: r'path\to\file'), + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + + test('escapes special characters in category id', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + /* cSpell:disable */ + categoryId: "cat'egory-1", + /* cSpell:enable */ + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'category-2', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + /* cSpell:disable */ + filters: const ProposalsFiltersV2(categoryId: "cat'egory-1"), + /* cSpell:enable */ + ); + + expect(result.items.length, 1); + expect(result.items[0].proposal.id, 'p1'); + }); + }); + + group('by latest update', () { + test('filters proposals created within duration', () async { + final now = DateTime.now(); + final oneHourAgo = now.subtract(const Duration(hours: 1)); + final twoDaysAgo = now.subtract(const Duration(days: 2)); + + final recentProposal = _createTestDocumentEntity( + id: 'recent', + ver: _buildUuidV7At(oneHourAgo), + createdAt: oneHourAgo, + ); + + final oldProposal = _createTestDocumentEntity( + id: 'old', + ver: _buildUuidV7At(twoDaysAgo), + createdAt: twoDaysAgo, + ); + + await db.documentsV2Dao.saveAll([recentProposal, oldProposal]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(latestUpdate: Duration(days: 1)), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'recent'); + }); + }); + + group('combined filters', () { + test('applies status and favorite filters together', () async { + final draftFavorite = _createTestDocumentEntity( + id: 'draft-fav', + ver: _buildUuidV7At(latest), + ); + + final draftNotFavorite = _createTestDocumentEntity( + id: 'draft-not-fav', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalFavorite = _createTestDocumentEntity( + id: 'final-fav', + ver: finalProposalVer, + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-fav', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([ + draftFavorite, + draftNotFavorite, + finalFavorite, + finalAction, + ]); + + await dao.updateProposalFavorite(id: 'draft-fav', isFavorite: true); + await dao.updateProposalFavorite(id: 'final-fav', isFavorite: true); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + status: ProposalStatusFilter.draft, + isFavorite: true, + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-fav'); + }); + + test('applies author, category, and search filters together', () async { + final author1 = _createTestAuthor(name: 'john', role0KeySeed: 1); + final author2 = _createTestAuthor(name: 'jane', role0KeySeed: 2); + + final matchingProposal = _createTestDocumentEntity( + id: 'matching', + ver: _buildUuidV7At(latest), + authors: author1.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongAuthor = _createTestDocumentEntity( + id: 'wrong-author', + ver: _buildUuidV7At(middle.add(const Duration(hours: 2))), + authors: author2.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongCategory = _createTestDocumentEntity( + id: 'wrong-category', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + authors: author1.toString(), + categoryId: 'cat-2', + contentData: { + 'setup': { + 'title': {'title': 'Blockchain Project'}, + }, + }, + ); + + final wrongTitle = _createTestDocumentEntity( + id: 'wrong-title', + ver: _buildUuidV7At(middle), + authors: author1.toString(), + categoryId: 'cat-1', + contentData: { + 'setup': { + 'title': {'title': 'Other Project'}, + }, + }, + ); + + await db.documentsV2Dao.saveAll([ + matchingProposal, + wrongAuthor, + wrongCategory, + wrongTitle, + ]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: ProposalsFiltersV2( + author: author1, + categoryId: 'cat-1', + searchQuery: 'Blockchain', + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'matching'); + }); + }); + + group('by campaign', () { + test('filters proposals by campaign categories', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result.items.length, 2); + expect(result.total, 2); + expect(result.items.map((e) => e.proposal.id).toSet(), {'p1', 'p2'}); + }); + + test('returns empty when campaign categories is empty', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle), + categoryId: 'cat-2', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {}), + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('combines categoryId with campaign filter when compatible', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + categoryId: 'cat-1', + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p1'); + expect(result.items[0].proposal.categoryId, 'cat-1'); + }); + + test('returns empty when categoryId not in campaign', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + categoryId: 'cat-3', + ), + ); + + expect(result.items.length, 0); + expect(result.total, 0); + }); + + test('ignores campaign filter when null', () async { + final proposal1 = _createTestDocumentEntity( + id: 'p1', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposal2 = _createTestDocumentEntity( + id: 'p2', + ver: _buildUuidV7At(middle.add(const Duration(hours: 1))), + categoryId: 'cat-2', + ); + + final proposal3 = _createTestDocumentEntity( + id: 'p3', + ver: _buildUuidV7At(middle), + categoryId: 'cat-3', + ); + + await db.documentsV2Dao.saveAll([proposal1, proposal2, proposal3]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2(campaign: null), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + }); + + test('handles null category_id in database', () async { + final proposalWithCategory = _createTestDocumentEntity( + id: 'p-with-cat', + ver: _buildUuidV7At(latest), + categoryId: 'cat-1', + ); + + final proposalWithoutCategory = _createTestDocumentEntity( + id: 'p-without-cat', + ver: _buildUuidV7At(middle), + categoryId: null, + ); + + await db.documentsV2Dao.saveAll([proposalWithCategory, proposalWithoutCategory]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1', 'cat-2'}), + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'p-with-cat'); + }); + + test('handles multiple categories efficiently', () async { + final proposals = List.generate( + 5, + (i) => _createTestDocumentEntity( + id: 'p-$i', + ver: _buildUuidV7At(earliest.add(Duration(hours: i))), + categoryId: 'cat-$i', + ), + ); + + await db.documentsV2Dao.saveAll(proposals); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters( + categoriesIds: {'cat-0', 'cat-2', 'cat-4'}, + ), + ), + ); + + expect(result.items.length, 3); + expect(result.total, 3); + expect( + result.items.map((e) => e.proposal.categoryId).toSet(), + {'cat-0', 'cat-2', 'cat-4'}, + ); + }); + + test('campaign filter respects status filter', () async { + final draftProposalVer = _buildUuidV7At(latest); + final draftProposal = _createTestDocumentEntity( + id: 'draft-p', + ver: draftProposalVer, + categoryId: 'cat-1', + ); + + final finalProposalVer = _buildUuidV7At(middle); + final finalProposal = _createTestDocumentEntity( + id: 'final-p', + ver: finalProposalVer, + categoryId: 'cat-1', + ); + + final finalActionVer = _buildUuidV7At(earliest); + final finalAction = _createTestDocumentEntity( + id: 'action-final', + ver: finalActionVer, + type: DocumentType.proposalActionDocument, + refId: 'final-p', + refVer: finalProposalVer, + contentData: ProposalSubmissionActionDto.aFinal.toJson(), + ); + + await db.documentsV2Dao.saveAll([draftProposal, finalProposal, finalAction]); + + const request = PageRequest(page: 0, size: 10); + final result = await dao.getProposalsBriefPage( + request: request, + filters: const ProposalsFiltersV2( + campaign: ProposalsCampaignFilters(categoriesIds: {'cat-1'}), + status: ProposalStatusFilter.draft, + ), + ); + + expect(result.items.length, 1); + expect(result.total, 1); + expect(result.items[0].proposal.id, 'draft-p'); + }); + }); + }); + }); + }); +} + +String _buildUuidV7At(DateTime dateTime) { + final ts = dateTime.millisecondsSinceEpoch; + final rand = Uint8List.fromList([42, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + return const UuidV7().generate(options: V7Options(ts, rand)); +} + +CatalystId _createTestAuthor({ + String? name, + int role0KeySeed = 0, +}) { + final buffer = StringBuffer('id.catalyst://'); + final role0Key = Uint8List.fromList(List.filled(32, role0KeySeed)); + + if (name != null) { + buffer + ..write(name) + ..write('@'); + } + + buffer + ..write('preprod.cardano/') + ..write(base64UrlNoPadEncode(role0Key)); + + final uri = Uri.parse(buffer.toString()); + + return CatalystId.fromUri(uri); } DocumentEntityV2 _createTestDocumentEntity({ diff --git a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart index 67e7fff2ab7e..43f011557831 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_services/lib/src/proposal/proposal_service.dart @@ -130,14 +130,20 @@ abstract interface class ProposalService { /// Streams changes to [isMaxProposalsLimitReached]. Stream watchMaxProposalsLimitReached(); - Stream> watchProposalsBriefPage({ + Stream> watchProposalsBriefPageV2({ required PageRequest request, + ProposalsOrder order, + ProposalsFiltersV2 filters, }); Stream watchProposalsCount({ required ProposalsCountFilters filters, }); + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters, + }); + Stream> watchProposalsPage({ required PageRequest request, required ProposalsFilters filters, @@ -506,10 +512,16 @@ final class ProposalServiceImpl implements ProposalService { } @override - Stream> watchProposalsBriefPage({ + Stream> watchProposalsBriefPageV2({ required PageRequest request, + ProposalsOrder order = const UpdateDate.desc(), + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), }) { - return _proposalRepository.watchProposalsBriefPage(request: request); + return _proposalRepository.watchProposalsBriefPage( + request: request, + order: order, + filters: filters, + ); } @override @@ -528,6 +540,13 @@ final class ProposalServiceImpl implements ProposalService { }); } + @override + Stream watchProposalsCountV2({ + ProposalsFiltersV2 filters = const ProposalsFiltersV2(), + }) { + return _proposalRepository.watchProposalsCountV2(filters: filters); + } + @override Stream> watchProposalsPage({ required PageRequest request, diff --git a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart index 3952e2db981a..20f5f50cf52d 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_view_models/lib/src/proposals/proposals_page_tab.dart @@ -1,13 +1 @@ -import 'package:catalyst_voices_models/catalyst_voices_models.dart'; - -enum ProposalsPageTab { - total(filter: ProposalsFilterType.total), - drafts(filter: ProposalsFilterType.drafts), - finals(filter: ProposalsFilterType.finals), - favorites(filter: ProposalsFilterType.favorites), - my(filter: ProposalsFilterType.my); - - final ProposalsFilterType filter; - - const ProposalsPageTab({required this.filter}); -} +enum ProposalsPageTab { total, drafts, finals, favorites, my }